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 ":" (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 When can("", ) is evaluated Then the result is 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 When is rendered Then 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 When the person opens Then the URL stays 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, , doesn't include it. Ask an owner or admin if you need it." And the single action is "" 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 When the person opens 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 "" and header
Then the call rejects with ApiError status 403, code ""
And 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 "" no longer says "" And it says "" 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=, content.delete:events= 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 Then the "View only" mark with a lock icon is And Edit is , with the reason "" And Delete is , with the reason "" 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=, content.delete:article=, content.update:article= When the Articles list and the editor of an article render Then Publish is And the status reads And Delete in the row menu is , with the reason "" And Edit is 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 When Media renders Then 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 calls Then the article objects 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