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:
1 parent
b146dfead7
commit
162fea4afb
26 files changed
+805
-45
No files matched your search
@@ -44,6 +44,7 @@ type UseCases struct {
|
|||||||
SendMailUseCase *useCases.SendMailUseCase
|
SendMailUseCase *useCases.SendMailUseCase
|
||||||
CreateImageUseCase *useCases.CreateImageUseCase
|
CreateImageUseCase *useCases.CreateImageUseCase
|
||||||
DeleteImageUseCase *useCases.DeleteImageUseCase
|
DeleteImageUseCase *useCases.DeleteImageUseCase
|
||||||
|
GetSettingsUseCase *useCases.GetSettingsUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
type Repositories struct {
|
type Repositories struct {
|
||||||
@@ -112,6 +113,7 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
|||||||
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
|
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
|
||||||
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo),
|
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo),
|
||||||
DeleteImageUseCase: useCases.NewDeleteImageUseCase(repos.ImageRepo),
|
DeleteImageUseCase: useCases.NewDeleteImageUseCase(repos.ImageRepo),
|
||||||
|
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
GetSettingsUseCase *useCases.GetSettingsUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(getSettings *useCases.GetSettingsUseCase) *Handler {
|
||||||
|
return &Handler{GetSettingsUseCase: getSettings}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetSettings(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
body, err := json.Marshal(h.GetSettingsUseCase.GetSettings())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"RenewCMS/api/handlers/auth"
|
"RenewCMS/api/handlers/auth"
|
||||||
"RenewCMS/api/handlers/frontend"
|
"RenewCMS/api/handlers/frontend"
|
||||||
"RenewCMS/api/handlers/image"
|
"RenewCMS/api/handlers/image"
|
||||||
|
"RenewCMS/api/handlers/settings"
|
||||||
"RenewCMS/api/handlers/staticimages"
|
"RenewCMS/api/handlers/staticimages"
|
||||||
"RenewCMS/api/handlers/user"
|
"RenewCMS/api/handlers/user"
|
||||||
"RenewCMS/api/middleware"
|
"RenewCMS/api/middleware"
|
||||||
@@ -115,6 +116,19 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewSettingsRouter(container *UseCases) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
settingsHandler := settings.NewHandler(container.GetSettingsUseCase)
|
||||||
|
|
||||||
|
r.Use(jwtauth.Verifier(container.Token))
|
||||||
|
r.Use(jwtauth.Authenticator(container.Token))
|
||||||
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
|
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||||
|
r.Get("/", settingsHandler.GetSettings)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
@@ -128,6 +142,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
|||||||
r.Mount("/article", NewArticleRouter(container))
|
r.Mount("/article", NewArticleRouter(container))
|
||||||
})
|
})
|
||||||
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
|
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
|
||||||
|
r.Mount("/settings", NewSettingsRouter(container))
|
||||||
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
|
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
|
||||||
|
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ func run() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
settings.Version = version
|
||||||
|
|
||||||
container, closeDb := api.InitContainer(settings)
|
container, closeDb := api.InitContainer(settings)
|
||||||
defer closeDatabase(closeDb)
|
defer closeDatabase(closeDb)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Feature: DashboardShell wordmark, sidebar collapse and the UserMenu
|
|||||||
AuthShell uses) followed by the "RenewCMS" wordmark text, not the plain
|
AuthShell uses) followed by the "RenewCMS" wordmark text, not the plain
|
||||||
<span class="font-semibold">RenewCMS</span> the shell renders today
|
<span class="font-semibold">RenewCMS</span> the shell renders today
|
||||||
And the sidebar's #default slot is a UNavigationMenu orientation="vertical" :collapsed bound
|
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
|
And the sidebar's #footer slot renders a UserMenu component, not the plain
|
||||||
<span>Log out</span> text the shell renders today
|
<span>Log out</span> text the shell renders today
|
||||||
And UserMenu is the account menu specified in features/profile_page.feature, anchored to a
|
And UserMenu is the account menu specified in features/profile_page.feature, anchored to a
|
||||||
|
|||||||
@@ -130,14 +130,14 @@ Feature: Nuxt UI shell foundation and router guards
|
|||||||
Then the alert above the title contains "Your session expired."
|
Then the alert above the title contains "Your session expired."
|
||||||
And the footer area outside the card contains a "Forgot password?" link
|
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
|
Given the current route is /article
|
||||||
When DashboardShell mounts
|
When DashboardShell mounts
|
||||||
Then the sidebar navigation lists exactly four destinations in this order: "Home" linking to
|
Then the sidebar navigation lists exactly five destinations in this order: "Home" linking to
|
||||||
/home, "Articles" linking to /article, "Users" linking to /users, and "Integration" linking
|
/home, "Articles" linking to /article, "Users" linking to /users, "Integration" linking
|
||||||
to /integration
|
to /integration, and "Settings" linking to /settings
|
||||||
And the "Articles" destination has aria-current="page"
|
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
|
Scenario: DashboardShell shows the offline banner when the browser reports offline
|
||||||
Given navigator.onLine is false
|
Given navigator.onLine is false
|
||||||
|
|||||||
@@ -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
|
13. **AuthShell notice/footer slots** — mount AuthShell with notice and footer slot content; assert
|
||||||
both render outside/above the card content as specified.
|
both render outside/above the card content as specified.
|
||||||
14. **DashboardShell nav + aria-current** — mount DashboardShell with current route /article; assert
|
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".
|
has aria-current="page".
|
||||||
15. **DashboardShell offline banner** — mock navigator.onLine false, mount DashboardShell; assert
|
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
|
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,
|
- [ ] AuthShell below sm: card loses border, 20px padding fills screen, fields/buttons use xl size,
|
||||||
light and dark.
|
light and dark.
|
||||||
- [ ] AuthShell footer: UColorModeSelect size="sm" plus footer links render outside the card.
|
- [ ] 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.
|
collapsing the sidebar leaves a 56px icon rail with tooltips on hover/focus.
|
||||||
- [ ] DashboardShell mobile (below lg): UDashboardSidebarToggle appears, sidebar becomes a
|
- [ ] DashboardShell mobile (below lg): UDashboardSidebarToggle appears, sidebar becomes a
|
||||||
slideover, closes after navigating.
|
slideover, closes after navigating.
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
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
|
"Profile" and the subtitle "Your account on this RenewCMS workspace."; none of the five
|
||||||
navigation items (Home, Articles, Users, Integration) has aria-current="page" on /profile
|
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,
|
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
|
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)
|
inputs are 40 px high (44 px on mobile, 16 px text)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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 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
|
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" 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
|
Articles, Users, Integration, Settings 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
|
"five navigation destinations" scenario of nuxt_ui_shell_foundation.feature and the items of
|
||||||
dashboard_shell_branding.feature are amended accordingly
|
dashboard_shell_branding.feature are amended accordingly
|
||||||
And the PageHeader shows breadcrumb "Users" (mobile title "Users")
|
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
|
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
|
Scenario: Navigation — "Users" sits between Articles and Integration
|
||||||
Given the user is on /users
|
Given the user is on /users
|
||||||
When DashboardShell mounts
|
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"
|
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
|
Scenario: Loading — skeleton rows after 150 ms while the first request, or a retry, is in flight
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
The invitations API is already built and covered by features/invitations.qa.md: this slice has no
|
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.
|
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
|
nuxt_ui_shell_foundation.feature / .qa.md (case 14) and dashboard_shell_branding.feature. Their
|
||||||
Vitest tests must be updated in the same change.
|
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,
|
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
|
"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.
|
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
|
2. **Navigation** — assert the sidebar and the drawer list Home, Articles, Users, Integration,
|
||||||
that order, and "Users" carries aria-current="page" on /users.
|
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
|
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.
|
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".
|
4. **Only you** — a single "marta" item; assert the row, the hint text and "Invite user".
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
type Settings struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ type Settings struct {
|
|||||||
Smtp SmtpSettings
|
Smtp SmtpSettings
|
||||||
AuthRateLimit AuthRateLimitSettings
|
AuthRateLimit AuthRateLimitSettings
|
||||||
TrustedProxies []string
|
TrustedProxies []string
|
||||||
|
Version string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s Settings) IsProduction() bool {
|
func (s Settings) IsProduction() bool {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package useCases
|
||||||
|
|
||||||
|
import "RenewCMS/internal/domain/settings"
|
||||||
|
|
||||||
|
type GetSettingsUseCase struct {
|
||||||
|
version string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGetSettingsUseCase(version string) *GetSettingsUseCase {
|
||||||
|
return &GetSettingsUseCase{version}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GetSettingsUseCase) GetSettings() settings.Settings {
|
||||||
|
return settings.Settings{Version: g.version}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/api/handlers/settings"
|
||||||
|
"RenewCMS/api/middleware"
|
||||||
|
"RenewCMS/api/pkg/apperr"
|
||||||
|
validator "RenewCMS/api/pkg/validator"
|
||||||
|
"RenewCMS/internal/domain/user"
|
||||||
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/jwtauth/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newSettingsRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth, version string) *chi.Mux {
|
||||||
|
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||||
|
settingsHandler := settings.NewHandler(useCases.NewGetSettingsUseCase(version))
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Route("/v1/settings", func(r chi.Router) {
|
||||||
|
r.Use(jwtauth.Verifier(token))
|
||||||
|
r.Use(jwtauth.Authenticator(token))
|
||||||
|
r.Use(middleware.RequireCurrentToken(getUserUseCase))
|
||||||
|
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
|
||||||
|
r.Get("/", settingsHandler.GetSettings)
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsHttp(t *testing.T) {
|
||||||
|
validator.InitValidator()
|
||||||
|
|
||||||
|
for _, version := range []string{"v2.2.0-beta", "dev"} {
|
||||||
|
t.Run("A verified caller gets the running build's version: "+version, func(t *testing.T) {
|
||||||
|
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||||
|
userRepo := newMockUserRepo()
|
||||||
|
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
|
||||||
|
router := newSettingsRouter(userRepo, token, version)
|
||||||
|
cookie := mintGetCurrentUserCookie(t, token, 7, 0)
|
||||||
|
|
||||||
|
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", cookie)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec.Body.String() != `{"version":"`+version+`"}` {
|
||||||
|
t.Errorf("unexpected body: %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("The response does not depend on the environment at request time", func(t *testing.T) {
|
||||||
|
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||||
|
userRepo := newMockUserRepo()
|
||||||
|
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
|
||||||
|
router := newSettingsRouter(userRepo, token, "v2.2.0-beta")
|
||||||
|
cookie := mintGetCurrentUserCookie(t, token, 7, 0)
|
||||||
|
t.Setenv("ENVIRONMENT", "development")
|
||||||
|
|
||||||
|
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", cookie)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec.Body.String() != `{"version":"v2.2.0-beta"}` {
|
||||||
|
t.Errorf("unexpected body: %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A request without a jwt cookie is refused", func(t *testing.T) {
|
||||||
|
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||||
|
router := newSettingsRouter(newMockUserRepo(), token, "v2.2.0-beta")
|
||||||
|
|
||||||
|
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", nil)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected status 401, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), "v2.2.0-beta") {
|
||||||
|
t.Errorf("body leaks the version: %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A token whose version no longer matches the account is refused", func(t *testing.T) {
|
||||||
|
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||||
|
userRepo := newMockUserRepo()
|
||||||
|
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 1}
|
||||||
|
router := newSettingsRouter(userRepo, token, "v2.2.0-beta")
|
||||||
|
cookie := mintGetCurrentUserCookie(t, token, 7, 0)
|
||||||
|
|
||||||
|
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", cookie)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected status 401, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
|
||||||
|
t.Errorf("unexpected body: %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("An unverified account is refused", func(t *testing.T) {
|
||||||
|
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||||
|
userRepo := newMockUserRepo()
|
||||||
|
userRepo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false, TokenVersion: 0}
|
||||||
|
router := newSettingsRouter(userRepo, token, "v2.2.0-beta")
|
||||||
|
cookie := mintGetCurrentUserCookie(t, token, 8, 0)
|
||||||
|
|
||||||
|
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", cookie)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected status 403, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec.Body.String() != apperr.VerificationRequiredErrorMessage+"\n" {
|
||||||
|
t.Errorf("unexpected body: %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), "v2.2.0-beta") {
|
||||||
|
t.Errorf("body leaks the version: %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetSettingsUseCase(t *testing.T) {
|
||||||
|
for _, version := range []string{"v2.2.0-beta", "dev"} {
|
||||||
|
t.Run("The use case returns the version it was built with: "+version, func(t *testing.T) {
|
||||||
|
useCase := useCases.NewGetSettingsUseCase(version)
|
||||||
|
|
||||||
|
got := useCase.GetSettings()
|
||||||
|
|
||||||
|
if got.Version != version {
|
||||||
|
t.Errorf("expected version %s, got %q", version, got.Version)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ const navItems = computed(() =>
|
|||||||
{ label: 'Articles', to: '/article', icon: 'i-lucide-file-text' },
|
{ label: 'Articles', to: '/article', icon: 'i-lucide-file-text' },
|
||||||
{ label: 'Users', to: '/users', icon: 'i-lucide-users' },
|
{ label: 'Users', to: '/users', icon: 'i-lucide-users' },
|
||||||
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
|
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
|
||||||
|
{ label: 'Settings', to: '/settings', icon: 'i-lucide-settings' },
|
||||||
].map((item) => ({
|
].map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
active: route.path === item.to || route.path.startsWith(`${item.to}/`),
|
active: route.path === item.to || route.path.startsWith(`${item.to}/`),
|
||||||
|
|||||||
@@ -58,14 +58,14 @@ afterEach(async () => {
|
|||||||
document.body.innerHTML = ''
|
document.body.innerHTML = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('DashboardShell renders the four navigation destinations', () => {
|
describe('DashboardShell renders the five navigation destinations', () => {
|
||||||
it('lists Home, Articles, Users and Integration in order, with only Articles carrying aria-current', async () => {
|
it('lists Home, Articles, Users, Integration and Settings in order, with only Articles carrying aria-current', async () => {
|
||||||
const wrapper = await mountShell('/article')
|
const wrapper = await mountShell('/article')
|
||||||
|
|
||||||
const hrefs = wrapper
|
const hrefs = wrapper
|
||||||
.findAll('nav[aria-label="Main"] a')
|
.findAll('nav[aria-label="Main"] a')
|
||||||
.map((link) => link.attributes('href'))
|
.map((link) => link.attributes('href'))
|
||||||
expect(hrefs).toEqual(['/home', '/article', '/users', '/integration'])
|
expect(hrefs).toEqual(['/home', '/article', '/users', '/integration', '/settings'])
|
||||||
|
|
||||||
const home = wrapper.find('a[href="/home"]')
|
const home = wrapper.find('a[href="/home"]')
|
||||||
const articles = wrapper.find('a[href="/article"]')
|
const articles = wrapper.find('a[href="/article"]')
|
||||||
@@ -78,6 +78,27 @@ describe('DashboardShell renders the four navigation destinations', () => {
|
|||||||
expect(integration.attributes('aria-current')).toBeUndefined()
|
expect(integration.attributes('aria-current')).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('lists the same five destinations in the mobile drawer and Settings current', async () => {
|
||||||
|
await mountShell('/settings', { width: MOBILE_WIDTH })
|
||||||
|
|
||||||
|
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
|
||||||
|
toggle.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const links = Array.from(
|
||||||
|
document.body.querySelectorAll('[role="dialog"] nav[aria-label="Main"] a'),
|
||||||
|
)
|
||||||
|
expect(links.map((link) => link.getAttribute('href'))).toEqual([
|
||||||
|
'/home',
|
||||||
|
'/article',
|
||||||
|
'/users',
|
||||||
|
'/integration',
|
||||||
|
'/settings',
|
||||||
|
])
|
||||||
|
expect(links[4].querySelector('[class*="lucide"], svg')).not.toBeNull()
|
||||||
|
expect(links[4].getAttribute('aria-current')).toBe('page')
|
||||||
|
})
|
||||||
|
|
||||||
it('marks Users with aria-current on /users', async () => {
|
it('marks Users with aria-current on /users', async () => {
|
||||||
const wrapper = await mountShell('/users')
|
const wrapper = await mountShell('/users')
|
||||||
|
|
||||||
@@ -211,11 +232,11 @@ describe('The account chip shows the display name and no item is current on /pro
|
|||||||
expect(trigger?.getAttribute('aria-label')).toBe('Marta Alves — account menu')
|
expect(trigger?.getAttribute('aria-label')).toBe('Marta Alves — account menu')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('marks none of the four navigation items current on /profile', async () => {
|
it('marks none of the five navigation items current on /profile', async () => {
|
||||||
const wrapper = await mountShell('/profile')
|
const wrapper = await mountShell('/profile')
|
||||||
|
|
||||||
const links = wrapper.findAll('nav[aria-label="Main"] a')
|
const links = wrapper.findAll('nav[aria-label="Main"] a')
|
||||||
expect(links).toHaveLength(4)
|
expect(links).toHaveLength(5)
|
||||||
links.forEach((link) => expect(link.attributes('aria-current')).toBeUndefined())
|
links.forEach((link) => expect(link.attributes('aria-current')).toBeUndefined())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
|
||||||
|
export interface WorkspaceSettings {
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSettings() {
|
||||||
|
const { request } = useApi()
|
||||||
|
|
||||||
|
function getSettings(): Promise<WorkspaceSettings> {
|
||||||
|
return request<WorkspaceSettings>('/v1/settings/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getSettings }
|
||||||
|
}
|
||||||
@@ -65,10 +65,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
|
|||||||
it('The first navigation to a non-public route resolves the session and shows AppBoot meanwhile', async () => {
|
it('The first navigation to a non-public route resolves the session and shows AppBoot meanwhile', async () => {
|
||||||
let resolveMe: (value: { verified: boolean }) => void = () => {}
|
let resolveMe: (value: { verified: boolean }) => void = () => {}
|
||||||
const getMe = vi.fn(
|
const getMe = vi.fn(
|
||||||
() =>
|
() => new Promise<{ verified: boolean }>((resolve) => (resolveMe = resolve)),
|
||||||
new Promise<{ verified: boolean }>(
|
|
||||||
(resolve) => (resolveMe = resolve),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
vi.mocked(useApi).mockReturnValue({ getMe })
|
vi.mocked(useApi).mockReturnValue({ getMe })
|
||||||
setUpSession()
|
setUpSession()
|
||||||
@@ -124,9 +121,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
|
|||||||
await router.push('/article')
|
await router.push('/article')
|
||||||
mountApp()
|
mountApp()
|
||||||
|
|
||||||
await vi.waitFor(() =>
|
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
|
||||||
expect(router.currentRoute.value.path).toBe('/register/pending'),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('A session-only route admits an unverified session', async () => {
|
it('A session-only route admits an unverified session', async () => {
|
||||||
@@ -201,9 +196,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
|
|||||||
)
|
)
|
||||||
expect(app.text()).toContain('Home')
|
expect(app.text()).toContain('Home')
|
||||||
|
|
||||||
const primaryButton = app
|
const primaryButton = app.findAll('button').find((button) => button.text() === 'Try again')
|
||||||
.findAll('button')
|
|
||||||
.find((button) => button.text() === 'Try again')
|
|
||||||
await primaryButton?.trigger('click')
|
await primaryButton?.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
@@ -219,16 +212,12 @@ describe('Nuxt UI shell foundation and router guards', () => {
|
|||||||
const app = mountApp()
|
const app = mountApp()
|
||||||
|
|
||||||
await vi.waitFor(() =>
|
await vi.waitFor(() =>
|
||||||
expect(app.find('[role="alert"] h1').text()).toBe(
|
expect(app.find('[role="alert"] h1').text()).toBe('Something went wrong'),
|
||||||
'Something went wrong',
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(router.currentRoute.value.path).toBe('/article')
|
expect(router.currentRoute.value.path).toBe('/article')
|
||||||
|
|
||||||
const primaryButton = app
|
const primaryButton = app.findAll('button').find((button) => button.text() === 'Try again')
|
||||||
.findAll('button')
|
|
||||||
.find((button) => button.text() === 'Try again')
|
|
||||||
await primaryButton?.trigger('click')
|
await primaryButton?.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
@@ -239,6 +228,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
|
|||||||
{ path: '/home', title: 'Home' },
|
{ path: '/home', title: 'Home' },
|
||||||
{ path: '/article', title: 'Articles' },
|
{ path: '/article', title: 'Articles' },
|
||||||
{ path: '/integration', title: 'Integration' },
|
{ path: '/integration', title: 'Integration' },
|
||||||
|
{ path: '/settings', title: 'Settings' },
|
||||||
])(
|
])(
|
||||||
'Placeholder verified route $path renders inside DashboardShell with title $title',
|
'Placeholder verified route $path renders inside DashboardShell with title $title',
|
||||||
async ({ path, title }) => {
|
async ({ path, title }) => {
|
||||||
@@ -251,14 +241,10 @@ describe('Nuxt UI shell foundation and router guards', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const headings = app.findAll('h1')
|
const headings = app.findAll('h1')
|
||||||
expect(headings.some((heading) => heading.text() === title)).toBe(
|
expect(headings.some((heading) => heading.text() === title)).toBe(true)
|
||||||
true,
|
|
||||||
)
|
|
||||||
expect(app.find('nav[aria-label="breadcrumb"]').text()).toBe(title)
|
expect(app.find('nav[aria-label="breadcrumb"]').text()).toBe(title)
|
||||||
|
|
||||||
const activeLink = app.find(
|
const activeLink = app.find(`a[href="${path}"][aria-current="page"]`)
|
||||||
`a[href="${path}"][aria-current="page"]`,
|
|
||||||
)
|
|
||||||
expect(activeLink.exists()).toBe(true)
|
expect(activeLink.exists()).toBe(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/IntegrationView.vue'),
|
component: () => import('@/views/IntegrationView.vue'),
|
||||||
meta: { auth: 'verified', title: 'Integration' },
|
meta: { auth: 'verified', title: 'Integration' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
component: () => import('@/views/SettingsView.vue'),
|
||||||
|
meta: { auth: 'verified', title: 'Settings' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
component: () => import('@/views/NotFoundView.vue'),
|
component: () => import('@/views/NotFoundView.vue'),
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import DashboardShell from '@/components/DashboardShell.vue'
|
||||||
|
import { ApiError } from '@/composables/useApi'
|
||||||
|
import { useSettings } from '@/composables/useSettings'
|
||||||
|
|
||||||
|
const SKELETON_DELAY_MS = 150
|
||||||
|
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
|
||||||
|
|
||||||
|
type LoadState = 'loading' | 'ready' | 'error'
|
||||||
|
|
||||||
|
const { getSettings } = useSettings()
|
||||||
|
|
||||||
|
const loadState = ref<LoadState>('loading')
|
||||||
|
const version = ref('')
|
||||||
|
const showSkeleton = ref(false)
|
||||||
|
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function clearSkeletonTimer() {
|
||||||
|
if (skeletonTimer !== null) {
|
||||||
|
clearTimeout(skeletonTimer)
|
||||||
|
skeletonTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHandledGlobally(error: unknown) {
|
||||||
|
return error instanceof ApiError && (error.status === 401 || error.status === 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
loadState.value = 'loading'
|
||||||
|
showSkeleton.value = false
|
||||||
|
clearSkeletonTimer()
|
||||||
|
skeletonTimer = setTimeout(() => {
|
||||||
|
showSkeleton.value = true
|
||||||
|
}, SKELETON_DELAY_MS)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settings = await getSettings()
|
||||||
|
version.value = settings.version
|
||||||
|
loadState.value = 'ready'
|
||||||
|
} catch (error) {
|
||||||
|
if (!isHandledGlobally(error)) {
|
||||||
|
loadState.value = 'error'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearSkeletonTimer()
|
||||||
|
showSkeleton.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadSettings)
|
||||||
|
onBeforeUnmount(clearSkeletonTimer)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DashboardShell>
|
||||||
|
<div class="flex max-w-180 flex-col gap-5">
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<h1 class="text-xl font-semibold text-highlighted">Settings</h1>
|
||||||
|
<p class="text-muted">Workspace information.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-testid="settings-card"
|
||||||
|
:aria-busy="loadState === 'loading' && showSkeleton ? 'true' : undefined"
|
||||||
|
class="border border-default bg-default p-4 lg:p-5"
|
||||||
|
>
|
||||||
|
<UAlert
|
||||||
|
v-if="loadState === 'error'"
|
||||||
|
role="alert"
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
orientation="vertical"
|
||||||
|
:close="false"
|
||||||
|
icon="i-lucide-circle-x"
|
||||||
|
:description="UNREACHABLE_COPY"
|
||||||
|
:ui="{ actions: 'mt-2' }"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-rotate-cw"
|
||||||
|
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
|
||||||
|
:ui="{ leadingIcon: 'size-3.5' }"
|
||||||
|
@click="loadSettings"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</UAlert>
|
||||||
|
|
||||||
|
<div v-else class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-lg font-semibold text-highlighted">Version</span>
|
||||||
|
<USkeleton
|
||||||
|
v-if="loadState === 'loading' && showSkeleton"
|
||||||
|
data-testid="settings-skeleton"
|
||||||
|
class="h-5 w-24 rounded-none bg-accented"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else-if="loadState === 'ready'"
|
||||||
|
data-testid="settings-version"
|
||||||
|
class="font-mono text-base text-toned"
|
||||||
|
>{{ version }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DashboardShell>
|
||||||
|
</template>
|
||||||
@@ -279,11 +279,11 @@ describe('Default: the usual first visit with no display name', () => {
|
|||||||
expect(request).not.toHaveBeenCalled()
|
expect(request).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('marks none of the four navigation items as the current page', async () => {
|
it('marks none of the five navigation items as the current page', async () => {
|
||||||
await mountApp()
|
await mountApp()
|
||||||
|
|
||||||
const links = document.body.querySelectorAll('nav[aria-label="Main"] a')
|
const links = document.body.querySelectorAll('nav[aria-label="Main"] a')
|
||||||
expect(links).toHaveLength(4)
|
expect(links).toHaveLength(5)
|
||||||
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
|
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { afterEach, 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 { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
||||||
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
import SettingsView from '../SettingsView.vue'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useApi: vi.fn(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const SETTINGS_PATH = '/v1/settings/'
|
||||||
|
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
|
||||||
|
|
||||||
|
let requestMock: ReturnType<typeof vi.fn>
|
||||||
|
let wrapper: VueWrapper | null = null
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
const promise = new Promise<T>((res) => {
|
||||||
|
resolve = res
|
||||||
|
})
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountPage(settings: () => Promise<unknown>) {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const session = useSessionStore()
|
||||||
|
session.session = { verified: true, username: 'marta', id: 1 }
|
||||||
|
session.status = 'ready'
|
||||||
|
requestMock = vi.fn((path: string) => {
|
||||||
|
if (path === SETTINGS_PATH) {
|
||||||
|
return settings()
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected request: ${path}`)
|
||||||
|
})
|
||||||
|
vi.mocked(useApi).mockReturnValue({
|
||||||
|
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
|
||||||
|
request: requestMock,
|
||||||
|
})
|
||||||
|
await router.push('/settings')
|
||||||
|
wrapper = mount(SettingsView, {
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { plugins: [router, uiPlugin] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
function card() {
|
||||||
|
return document.body.querySelector('[data-testid="settings-card"]')!
|
||||||
|
}
|
||||||
|
|
||||||
|
function versionValue() {
|
||||||
|
return document.body.querySelector('[data-testid="settings-version"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
function settingsCalls() {
|
||||||
|
return requestMock.mock.calls.filter(([path]) => path === SETTINGS_PATH)
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryAgainButton() {
|
||||||
|
return Array.from(document.body.querySelectorAll('button')).find(
|
||||||
|
(button) => button.textContent?.trim() === 'Try again',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
wrapper = null
|
||||||
|
vi.useRealTimers()
|
||||||
|
await router.push('/login')
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Default: the Version row shows the server version', () => {
|
||||||
|
it('shows the breadcrumb, the header, the label and the value, with Settings current', async () => {
|
||||||
|
await mountPage(() => Promise.resolve({ version: 'v2.2.0-beta' }))
|
||||||
|
|
||||||
|
expect(
|
||||||
|
document.body.querySelector('nav[aria-label="breadcrumb"]')?.textContent?.trim(),
|
||||||
|
).toBe('Settings')
|
||||||
|
expect(document.body.querySelector('h1')?.textContent?.trim()).toBe('Settings')
|
||||||
|
expect(document.body.textContent).toContain('Workspace information.')
|
||||||
|
expect(card().textContent).toContain('Version')
|
||||||
|
expect(versionValue()?.textContent?.trim()).toBe('v2.2.0-beta')
|
||||||
|
expect(card().hasAttribute('aria-busy')).toBe(false)
|
||||||
|
expect(settingsCalls()).toHaveLength(1)
|
||||||
|
|
||||||
|
const current = document.body.querySelectorAll(
|
||||||
|
'nav[aria-label="Main"] a[aria-current="page"]',
|
||||||
|
)
|
||||||
|
expect(current).toHaveLength(1)
|
||||||
|
expect(current[0].getAttribute('href')).toBe('/settings')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the value exactly as reported, with no prefix added', async () => {
|
||||||
|
await mountPage(() => Promise.resolve({ version: 'dev' }))
|
||||||
|
|
||||||
|
expect(versionValue()?.textContent?.trim()).toBe('dev')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('holds no action, link or input inside the card', async () => {
|
||||||
|
await mountPage(() => Promise.resolve({ version: 'v2.2.0-beta' }))
|
||||||
|
|
||||||
|
expect(card().querySelector('button, a, input')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Router meta', () => {
|
||||||
|
it('declares /settings as a verified route titled Settings', () => {
|
||||||
|
const route = router.getRoutes().find((candidate) => candidate.path === '/settings')
|
||||||
|
|
||||||
|
expect(route?.meta.auth).toBe('verified')
|
||||||
|
expect(route?.meta.title).toBe('Settings')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Loading: skeleton for the value after 150 ms', () => {
|
||||||
|
it('shows the label at once, a skeleton from 150 ms, then the value', async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||||
|
const pending = deferred<{ version: string }>()
|
||||||
|
await mountPage(() => pending.promise)
|
||||||
|
|
||||||
|
expect(card().textContent).toContain('Version')
|
||||||
|
vi.advanceTimersByTime(149)
|
||||||
|
await flushPromises()
|
||||||
|
expect(card().hasAttribute('aria-busy')).toBe(false)
|
||||||
|
expect(card().querySelector('.animate-pulse')).toBeNull()
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1)
|
||||||
|
await flushPromises()
|
||||||
|
expect(card().getAttribute('aria-busy')).toBe('true')
|
||||||
|
expect(card().querySelector('.animate-pulse')).not.toBeNull()
|
||||||
|
expect(card().textContent).toContain('Version')
|
||||||
|
|
||||||
|
pending.resolve({ version: 'v2.2.0-beta' })
|
||||||
|
await flushPromises()
|
||||||
|
expect(card().querySelector('.animate-pulse')).toBeNull()
|
||||||
|
expect(card().hasAttribute('aria-busy')).toBe(false)
|
||||||
|
expect(versionValue()?.textContent?.trim()).toBe('v2.2.0-beta')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Error: a 500 or a network failure replaces the row with an alert and a retry', () => {
|
||||||
|
it.each([
|
||||||
|
{ name: 'a 500 response', failure: () => Promise.reject(new ApiError(500)) },
|
||||||
|
{ name: 'a network failure', failure: () => Promise.reject(new NetworkError()) },
|
||||||
|
])('shows the alert and recovers after Try again on $name', async ({ failure }) => {
|
||||||
|
await mountPage(failure)
|
||||||
|
|
||||||
|
const alert = card().querySelector('[role="alert"]')!
|
||||||
|
expect(alert.textContent).toContain(UNREACHABLE_COPY)
|
||||||
|
expect(alert.querySelector('button[aria-label="Close"]')).toBeNull()
|
||||||
|
expect(tryAgainButton()).toBeTruthy()
|
||||||
|
expect(card().textContent).not.toContain('Version')
|
||||||
|
expect(document.body.querySelector('h1')?.textContent?.trim()).toBe('Settings')
|
||||||
|
expect(document.body.textContent).toContain('Workspace information.')
|
||||||
|
|
||||||
|
requestMock.mockImplementation(() => Promise.resolve({ version: 'v2.2.0-beta' }))
|
||||||
|
tryAgainButton()?.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(settingsCalls()).toHaveLength(2)
|
||||||
|
expect(card().querySelector('[role="alert"]')).toBeNull()
|
||||||
|
expect(versionValue()?.textContent?.trim()).toBe('v2.2.0-beta')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('A 401 or an unverified 403 is handled globally, not by the page', () => {
|
||||||
|
it.each([401, 403])('shows no error alert of its own on a %i', async (status) => {
|
||||||
|
await mountPage(() => Promise.reject(new ApiError(status)))
|
||||||
|
|
||||||
|
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||||
|
expect(tryAgainButton()).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -869,6 +869,15 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
},
|
},
|
||||||
'Invite sent': { path: '/users', skip: 'clock-sensitive' },
|
'Invite sent': { path: '/users', skip: 'clock-sensitive' },
|
||||||
},
|
},
|
||||||
|
settings: {
|
||||||
|
Default: {
|
||||||
|
path: '/settings',
|
||||||
|
api: {
|
||||||
|
'GET /v1/auth/me': verifiedSession,
|
||||||
|
'GET /v1/settings/': { status: 200, body: { version: 'v2.2.0-beta' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
integration: {
|
integration: {
|
||||||
Default: {
|
Default: {
|
||||||
path: '/integration',
|
path: '/integration',
|
||||||
|
|||||||
Reference in new issue
Block a user