Files
RenewCMS/features/roles_permissions_spa_foundation.feature
T

332 lines
26 KiB
Gherkin

Feature: SPA foundation for roles and permissions (slice 5)
As a signed-in person with a custom or built-in role
I want the app to know exactly what my role allows, from the server's answer
So that every screen, route, control and error speaks in permissions, never in fixed role names
Background:
Given the backend contract of features/roles_permissions_{foundation,enforcement,content_scope,roles_api,users_api}.feature
And GET /v1/auth/me answers access {role: {id, name, built_in, locked}, permissions: [{key, scope}]} and no top-level role string
And the 34-key catalogue, scopes none|own|all, typed entries "<key>:<type_key>" (article for articles) that replace the wildcard for that type, a "none" entry denying
And toggle keys arrive with scope "all"; owner-only keys arrive only for the Owner role
And a refusal is a plain-text 403 body: missing_permission with X-Missing-Permission (base key), escalation with X-Escalation-Key, or role_locked, own_role, role_built_in, built_in_name, role_conflict (409), session_required ("This action needs a real session.")
And own means created_by equals the caller's user id; created_by 0 is owned by nobody
And the ownership data the SPA can see today is: entries carry created_by (GET /v1/content-type/{key}/entry and /{id}); articles did not (Article.CreatedBy was json:"-"); this slice exposes it to signed-in callers, see the backend Rule; media items have no owner and the media delete, merge and purge routes require scope all
And the SPA lives in web/src, Nuxt UI only, design tokens only, EN and FR copy in i18n/messages.ts, no comments in code
And the test runner is web/vitest.config.ts (jsdom) run by `task test:web`; it already exists, this slice runs a baseline first
Rule: The session carries access, and can() decides
Scenario: getMe maps the access object
Given GET /v1/auth/me answers 200 with access.role {id: 3, name: "Writer", built_in: true, locked: false} and 9 permission entries
When useApi().getMe() resolves
Then the session holds role {id 3, name "Writer", built_in true, locked false} and the 9 entries in order
And the session has no string role field and no isOwner / canManageContent getter exists
Scenario Outline: can(key, options) resolves the effective scope
Given the session grants <grants>
When can("<key>", <options>) is evaluated
Then the result is <result>
Examples:
| grants | key | options | result |
| content.update=all | content.update | {} | true |
| content.update=own | content.update | {} | true |
| content.update=own | content.update | {scope: "all"} | false |
| content.update=own, session id 7 | content.update | {ownerId: 7} | true |
| content.update=own, session id 7 | content.update | {ownerId: 8} | false |
| content.update=own, session id 7 | content.update | {ownerId: 0} | false |
| content.update=all | content.update | {ownerId: 8} | true |
| content.update=none | content.update | {} | false |
| key absent | content.update | {} | false |
| content.update=own, content.update:events=all | content.update | {typeKey: "events", scope: "all"} | true |
| content.update=all, content.update:events=none | content.update | {typeKey: "events"} | false |
| content.update=all, content.update:events=none | content.update | {typeKey: "venues"} | true |
| content.read=all | content.read | {typeKey: "article"} | true |
| settings.view=all | content.explode | {} | false |
| no session | media.view | {} | false |
Scenario: scopeOf exposes none, own or all
Given the session grants content.read=all and content.read:events=own
Then scopeOf("content.read") is "all"
And scopeOf("content.read", {typeKey: "events"}) is "own"
And scopeOf("schema.view") is "none"
Scenario: Live refresh after a refused action
Given the session grants content.delete=all
And the server now answers 403 missing_permission with X-Missing-Permission "content.delete" and GET /v1/auth/me then lacks content.delete
When the SPA receives that 403
Then GET /v1/auth/me is requested once more (not more than once every 5 seconds)
And can("content.delete") becomes false without a reload
Scenario: Live refresh when the tab comes back
Given the last /v1/auth/me answer is 31 seconds old
When the document becomes visible
Then the session is refreshed
And when the last answer is 10 seconds old, it is not
Scenario: A failed refresh keeps the previous grants
Given GET /v1/auth/me fails with a network error
Then the session keeps its last grants and shows no error
Rule: Every fixed-role check is replaced by a permission
Scenario Outline: Permission replaces the role string
Given the session grants only <grants>
When <place> is rendered
Then <effect>
Examples:
| grants | place | effect |
| users.invite | Users, Invite action | the Invite button is shown |
| users.view | Users, Invite action | the Invite button is hidden |
| apikeys.view=all | API keys list | the Created by column is shown |
| apikeys.view=own | API keys list | the Created by column is hidden |
| media.delete=all | Media, Duplicates | the purge control is available |
| media.delete=own | Media, Duplicates | the purge control is hidden (purge routes need scope all) |
| settings.edit_general | Cache, Variants, Media cleanup | controls are editable |
| settings.view | Cache, Variants, Media cleanup | read-only with the ReadOnlyBanner |
| activity.view=all | Home recent activity, Activity page | full view |
| activity.view=own | Home recent activity, Activity page | own events only |
| activity.view=none | Home recent activity | the card is not shown |
| authors.create | Authors, Add author button, MetaSection | shown / inline create enabled |
| authors.view | Authors | read-only list and detail |
| system.apply_updates | Settings, Software card, Home banner | Apply and Restart are actionable, cue shown |
| settings.view | Settings, Software card | the Apply action is disabled with the "Owner only" tag |
| system.edit_retention | Settings, Retention card | editable |
| settings.view | Settings, Retention card | visible, locked, "Owner only" tag |
| settings.manage_sites | Sidebar, Settings children | the Sites child is shown |
| settings.manage_sites | Sites, Delete | Delete is disabled with the "Owner only" tag |
| settings.manage_sites, settings.delete_site | Sites, Delete | Delete is enabled |
Scenario: Sidebar lists items per permission
Given the session grants only content.read, media.view, authors.view, activity.view=own
Then the sidebar shows Home, Articles, Media, Authors, Activity and Integration
And it shows no Users, no Schema, no Settings
And the mobile drawer shows the same items
And the Access / Roles entry is not added in this slice
Scenario: The Writer built-in sees the Writer menu
Given the session is the built-in Writer (9 grants)
Then the sidebar shows Home, Articles, Media, Authors, Schema, Activity, Integration and no Users and no Settings
Scenario: The Admin built-in loses only the owner-only actions
Given the session is the built-in Admin (29 keys)
Then Sites is reachable and Delete site is disabled with the "Owner only" tag
And Apply update is disabled with "Only the owner can apply updates."
And the Retention card reads "Admins can read the log. Only the owner can change how long it is kept."
Rule: Route guard by permission
Scenario Outline: A direct link to a page the role lacks shows the page-level 403 state
Given the session grants <grants>
When the person opens <route>
Then the URL stays <route> and no redirect happens
And the page shows the icon-lock state titled "You don't have access to this page"
And the description is "Your role, <role>, doesn't include it. Ask an owner or admin if you need it."
And the single action is "<action>"
Examples:
| grants | role | route | action |
| content.read=all, no schema | Contributor | /schema | Go to Articles |
| content.read=all | Contributor | /schema/new | Go to Articles |
| content.read=all | Contributor | /users | Go to Articles |
| content.read=all | Contributor | /settings/sites | Go to Articles |
| content.read=all | Contributor | /integration/api-keys | Go to Articles |
| content.read:events=none | Contributor | /content/events | Go to Articles |
| media.view only | Media | /schema | Go to Home |
Scenario Outline: A page the role can use renders normally
Given the session grants <grants>
When the person opens <route>
Then the page renders and no 403 state shows
Examples:
| grants | route |
| schema.view | /schema |
| schema.edit | /schema/new |
| users.view | /users |
| settings.manage_sites | /settings/sites |
| content.read:events=own | /content/events |
| content.create:events=all | /content/events/new |
| apikeys.view=own | /integration/api-keys |
| settings.view | /settings/media/cache |
Scenario: The guard waits for the session
Given the session status is "checking"
When the person opens /schema
Then neither the page nor the 403 state flashes
And once the status is "ready" the matching state shows
Scenario: Unverified and signed-out rules are unchanged
Given an unverified session
Then the person is sent to /register/pending
And a missing session goes to /login with redirect and reason=expired
Scenario: Grants lowered while a page is open
Given the person is on /schema with schema.view
And a refresh then returns no schema.view
Then the page switches to the page-level 403 state without reload
Rule: Error handling in useApi
Scenario Outline: A 403 with a code
When the server answers 403 with body "<body>" and header <header>
Then the call rejects with ApiError status 403, code "<body>"<details>
And <toast>
Examples:
| body | header | details | toast |
| missing_permission | X-Missing-Permission: content.delete | permission "content.delete" | an error toast "You don't have permission to do that" / "Your role doesn't include Delete entries. Ask an owner or admin." |
| missing_permission | X-Missing-Permission: users.invite | permission "users.invite" | the toast names "Invite users" |
| missing_permission | (none) | permission undefined | the toast reads "Your role doesn't include this action. Ask an owner or admin." |
| escalation | X-Escalation-Key: content.publish:events| escalationKey kept | no global toast; the screen shows "You can't grant that" / "You can only grant what your role has." |
| role_locked | (none) | code kept | no global toast; copy "This role is locked" / "The Owner role can't be changed or deleted. Transfer ownership from Users instead." is available to screens |
| own_role | (none) | code kept | no global toast; copy "You can't change your own role" / "Ask another owner or admin." |
| role_built_in | (none) | code kept | no global toast; copy "Built-in roles can't be deleted" / "Duplicate it to make a custom role." |
| built_in_name | (none) | code kept | no global toast; copy "Built-in roles keep their name." |
| session_required | (none) | code kept | no global toast |
Scenario: A caller can opt out of the toast
Given a request sent with onMissingPermission "passthrough"
When the server answers 403 missing_permission
Then the ApiError carries the code and permission and no toast shows
And the session is still refreshed
Scenario: The unverified redirect still wins
Given the session is unverified
When any call answers 403
Then the person is sent to /register/pending, as before
Scenario: Unchanged statuses
Then 401 still redirects to /login, 429 still carries Retry-After, network failures still set isOffline
Scenario: 409 role_conflict and user_role_changed are mapped, not toasted
When the server answers 409 "role_conflict" or "user_role_changed"
Then ApiError.code carries it and useApi shows no toast
And a screen without its own handling shows "This changed somewhere else. Reload and try again."
Scenario: The old role-specific toasts are retired
Given the Sites or Authors write answers 403 missing_permission
Then the generic missing_permission toast shows
And "Only the owner can manage sites" no longer shows anywhere
Rule: Copy follows the new Admin rights
Scenario Outline: Owner-only copy
Then "<screen>" no longer says "<old>"
And it says "<new>"
Examples:
| screen | old | new |
| Sites 403 page | Only the owner can manage sites… | the page-level state |
| Schema 403 page | Only owners and admins can change content types… | the page-level state |
| Settings, Software | Ask the owner of this site to update RenewCMS. | Only the owner can apply updates. |
| Settings, Retention | Only owners can change it. (hidden for admin) | Admins can read the log. Only the owner can change how long it is kept. |
| Settings, Media cleanup | Only owners and admins can change this. | You can read these settings but not change them / Ask the owner to change them, or ask for a role that can edit settings. |
Rule: Own-scope and role-aware controls on content (design: roles.md section 6)
Scenario Outline: Entry list rows under each scope for content.update and content.delete
Given the session is user 21 with content.update:events=<update>, content.delete:events=<delete>
And entry 1 has created_by 21, entry 2 has created_by 9, entry 3 has created_by 0
When /content/events lists the three entries and the row menu opens on <row>
Then the "View only" mark with a lock icon is <mark>
And Edit is <edit>, with the reason "<editReason>"
And Delete is <del>, with the reason "<delReason>"
Examples:
| update | delete | row | mark | edit | editReason | del | delReason |
| own | own | entry 1 | absent | enabled | | enabled | |
| own | own | entry 2 | shown | disabled | You can only edit content you created. | disabled | You can only edit content you created. |
| own | own | entry 3 | shown | disabled | You can only edit content you created. | disabled | You can only edit content you created. |
| all | all | entry 2 | absent | enabled | | enabled | |
| all | none | entry 1 | absent | enabled | | disabled | Your role can't delete entries. |
| none | none | entry 1 | shown | disabled | Your role can't edit entries. | disabled | Your role can't delete entries. |
Scenario: The entry list explains the own scope
Given the session has content.create:events=all and content.update:events=own
Then the Events list reads "You can create entries and edit your own."
And a note reads "Entries by others are view only" / "Your role lets you edit entries you created."
And the New entry button is shown
And with content.create:events=none the New entry button is hidden
Scenario: Direct link to an entry the role can only view
Given the session has content.update:events=own and entry 2 has created_by 9
When the person opens /content/events/2
Then the entry is shown with the "View only" mark and no Save action
And the exact layout beyond the mark is a design gap (not specified in roles.md)
Scenario Outline: Article controls under all and none (ownership not needed)
Given the session has content.publish:article=<publish>, content.delete:article=<delete>, content.update:article=<update>
When the Articles list and the editor of an article render
Then Publish is <publishState>
And the status reads <status>
And Delete in the row menu is <del>, with the reason "<delReason>"
And Edit is <edit>
Examples:
| publish | delete | update | publishState | status | del | delReason | edit |
| all | all | all | available | Draft or Published as today | enabled | | enabled |
| none | none | all | hidden | Draft with "Ask an editor to publish." | disabled | Your role can't delete articles. | enabled |
| none | none | none | hidden | Draft with "Ask an editor to publish." | disabled | Your role can't delete articles. | disabled |
Scenario: The Writer built-in Articles list
Given the session is the built-in Writer
Then the Articles header reads "Articles" with "You can write and edit all articles. Publishing is done by an editor."
And the row menu shows Edit enabled and Delete disabled with "Your role can't delete articles."
And the editor shows Save draft, Status "Draft" and "Ask an editor to publish." and no Publish
Scenario Outline: Article controls under own scope
Given the session is user 21 with content.update:article=own, content.delete:article=own, content.publish:article=own
And article 1 has created_by 21 and article 2 has created_by 9, both exposed by GET /v1/article/
When the Articles list renders
Then on article 1 Edit, Delete and Publish are available
And on article 2 Edit and Delete are disabled with "You can only edit content you created." and Publish is hidden with "Ask an editor to publish."
Scenario Outline: Media controls
Given the session grants <grants>
When Media renders
Then <effect>
Examples:
| grants | effect |
| media.view | the library is read-only: no upload control, no Rename, no Delete, no Merge, no purge |
| media.view, media.upload | upload and Rename available; Delete, Merge and purge hidden |
| media.view, media.delete=all | Delete, Merge and purge available |
| media.view, media.delete=own | Delete, Merge and purge unavailable: media has no owner and the server requires all |
| no media.view | the page-level 403 state |
Scenario: A stale menu still ends well
Given the person sees Delete on an article and the role was just lowered
When Delete answers 403 missing_permission
Then the toast "You don't have permission to do that" / "Your role doesn't include Delete entries. Ask an owner or admin." shows
And the session refreshes and the control becomes disabled with its reason
Rule: Shapes of the changed API are documented in the catalogue
Scenario: The catalogue samples match the backend
Then the /auth/me sample shows access.role and access.permissions and no role string
And the /user list sample shows role {id, name, built_in, locked}
And the invite create request documents the optional role_id (absent means Writer) and the response carries role
And docs/llms output is regenerated by `task build:llms` from the catalogue, never edited by hand
Rule: The backend exposes created_by on articles to signed-in callers (Go, Go test suite)
Scenario Outline: created_by on article responses
When <caller> calls <route>
Then the article objects <result>
Examples:
| caller | route | result |
| a signed-in session | GET /v1/article/ | carry created_by (the user id) |
| a signed-in session | GET /v1/article/{id} | carry created_by |
| a signed-in session | PATCH /v1/article/{id} (response) | carry created_by |
| a signed-in session | POST /v1/article (response) | carry created_by, the caller's id |
| an API key | GET /v1/article/ | carry created_by (a key acts as its creator, authenticated) |
| an anonymous caller | GET /v1/article/ and GET /v1/article/{id} | carry no created_by field at all |
| an unverified or stale session | GET /v1/article/ | carry no created_by field (treated as anonymous, as in the public read rule) |
And created_by 0 is returned as 0 for a signed-in caller
And the same article encoded for an anonymous caller and a signed-in caller differ only by that field
And docs/api.md and docs/api/articles.md document the field, the audience and the SPA use; the Go suite covers every row with the existing optionalAccess middleware deciding authenticated versus anonymous