feat: roles SPA foundations
This commit is contained in:
1 parent
80f7037d3a
commit
907949aa12
180 files changed
+4308
-835
No files matched your search
@@ -0,0 +1,331 @@
|
||||
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
|
||||
@@ -0,0 +1,71 @@
|
||||
# QA procedure: SPA foundation for roles and permissions (slice 5)
|
||||
|
||||
Spec: features/roles_permissions_spa_foundation.feature. Everything below is Vitest + Vue Test Utils under `task test:web` with `/v1` mocked through the existing kits. No live server, no real SMTP.
|
||||
|
||||
## Honesty rules
|
||||
0. The runner already exists (web/vitest.config.ts, `test:web` in Taskfile.yml, vitest and @vue/test-utils in web/package.json). First run `task test:web` on the unmodified tree and record exit code and failing specs. That is the baseline.
|
||||
1. If the runner cannot execute (bun missing, install failing), every SPA item is reported NOT RUN, never passing. A green Go suite never counts for the SPA.
|
||||
2. Report each edited spec, and which ones already failed in the baseline.
|
||||
3. The backend change (created_by on article responses for signed-in callers) is part of this slice and is covered by Go tests in test/; own-scope article specs are normal specs, not todo.
|
||||
4. The sentence in CLAUDE.md saying the runner is added by the first SPA slice is stale; the coordinator updates it.
|
||||
|
||||
## Shared helper
|
||||
`web/src/test/access.ts` (new) builds the Owner (34, all), Admin (29, no owner-only key), Writer (9 grants of roles_permissions_foundation.feature), custom and none presets. Kits keep their `role` option and call it; no spec writes a raw role string. Legacy mapping: owner, admin, member and user become Owner, Admin, Writer and no-grants; undefined becomes no `access`.
|
||||
|
||||
## Automated (Vitest)
|
||||
|
||||
| Spec | Cases |
|
||||
|---|---|
|
||||
| stores/__tests__/permissions.spec.ts (new, replaces sessionRoles.spec.ts) | every row of the can() table; scopeOf; no session; typed none; wildcard for a type without row; unknown key false |
|
||||
| composables/__tests__/useApi.spec.ts (extend or new) | getMe maps access and drops role; 403 matrix (missing_permission with and without header, escalation, role_locked, own_role, role_built_in, built_in_name, session_required); passthrough option; refresh debounce 5 s and visibility refresh 31 s vs 10 s with fake timers (no real dates); failed refresh keeps grants; unverified redirect wins; 401, 429, network unchanged; 409 codes mapped without toast |
|
||||
| lib/__tests__/permissionNames.spec.ts (new) | all 34 keys have an EN and FR human name; content.delete is "Delete entries"; a typed key shows the base-key name |
|
||||
| router/__tests__/routerGuard.spec.ts (extend) | each route of the guard table denied and allowed; status checking shows nothing; unverified and signed-out unchanged; typed param route /content/events; lowered-while-open switch; URL unchanged |
|
||||
| components/__tests__/NoAccessState.spec.ts (new) | title, description with role name, single action Articles vs Home, no "See what I can do" |
|
||||
| components/__tests__/OwnerOnlyTag.spec.ts, ReadOnlyBanner.spec.ts, ViewOnlyMark.spec.ts (new) | lock icon, texts EN and FR, tooltip reason |
|
||||
| components/__tests__/DashboardShell.spec.ts (update) | Writer, Admin, Owner, custom menus; drawer equals sidebar; no Roles entry |
|
||||
| views/__tests__/UsersView.spec.ts, UsersRowActions.spec.ts | users.invite shows Invite; role object rendered through RoleBadge (Owner locked, Admin built-in, custom, null) |
|
||||
| views/__tests__/ApiKeysView.spec.ts | apikeys.view all vs own for Created by |
|
||||
| views/__tests__/Settings{Updates,UpdateDownload,Restart,ContainerUpdates,Retention,MediaCleanup}.spec.ts, HomeUpdate*.spec.ts, stores/__tests__/update.spec.ts | system.apply_updates, system.edit_retention, settings.edit_general gating; Owner only tag; Admin Retention state |
|
||||
| views/__tests__/SitesView.spec.ts, Schema*.spec.ts, composables/__tests__/useSchemaGate.spec.ts | page-level state replaces the old forbidden copy; Delete site disabled for Admin |
|
||||
| views/__tests__/Authors*.spec.ts, EditorAddAuthor.spec.ts, CachePage.spec.ts, VariantsPage.spec.ts, Activity*.spec.ts, Home*.spec.ts | kits emit the new access body; owner and admin behaviour unchanged; permission-driven cases |
|
||||
| views/__tests__/ContentList*.spec.ts, EntryEditor*.spec.ts (extend) | the entry table of the feature: View only mark, Edit and Delete disabled with their exact reasons, own note, New entry hidden without create, direct link to a viewed entry shows View only and no Save |
|
||||
| views/__tests__/Articles*.spec.ts, Editor*.spec.ts, composables PublishControl / useArticleRowActions specs (extend) | all/none table: Publish hidden with "Ask an editor to publish.", Delete disabled with "Your role can't delete articles.", Writer header copy, stale-menu 403 toast then refresh and disabled control |
|
||||
| same | own-scope article rows (created_by from the API) |
|
||||
| views/__tests__/MediaView.spec.ts, MediaUpload.spec.ts, mediaTestKit | the media table: upload, rename, delete, merge, purge by grants; own behaves as none; no media.view shows the page state |
|
||||
| data/__tests__/apiCatalogue.spec.ts | sample shapes; no role string in me or user list; role_id documented |
|
||||
| i18n/spa.spec.ts (update) | new keys exist in EN and FR |
|
||||
|
||||
## Visual (`task visual:check -- <screen>`, hard gate 10 %)
|
||||
Step 1. After migrating the `/auth/me` mocks in web/visual/states.ts (about 48) to access/helper bodies, run every screen of `visualStates`. Everything not listed below must still PASS; clock-sensitive states stay `skip: 'clock-sensitive'`.
|
||||
|
||||
Step 2. States whose rendering changes by design and whose board frames still carry the old or different copy. The user approved per-state exceptions in the slice 5 acceptance. Each is recorded in web/visual/states.ts as `exception` with `approvedBy: 'user (slice 5 acceptance)'` ONLY if the run prints FAIL for it (do not add an exception for a state that passes). Candidates, from a static read of states.ts:
|
||||
|
||||
| Screen | State | Why it may fail |
|
||||
|---|---|---|
|
||||
| sites | Forbidden (403) | old "You don't have access to Sites" copy replaced by the page-level state |
|
||||
| schema | Forbidden (403) | old "Only owners and admins can change content types" copy replaced |
|
||||
| variants | Forbidden | the permission guard shows the page-level state instead of the local one |
|
||||
| activity | Forbidden (403) | same, only if the mock session now lacks activity.view |
|
||||
| content | Forbidden (403) | same, only if the mock now lacks content.read for the type |
|
||||
| settings | Retention for an admin (board shows it hidden for non-owners), Software for an admin (Owner only tag) | new Owner-only treatment |
|
||||
| settings / cache / variants | Media cleanup read-only | new read-only copy and ReadOnlyBanner |
|
||||
| users | Invite action gating, role badge from a role object | badge rendering from RoleRef |
|
||||
|
||||
Step 3. New or changed rendering with no board frame: NoAccessState (reference frame "Page: no access" in roles.md section 7), ReadOnlyBanner and OwnerOnlyTag ("Admin: read-only settings", "Admin: owner-only settings"), the View only mark and disabled row menus ("Own scope: entry list", "Own scope: row menu", "Writer: Articles list", "Writer: row menu", "Writer: entry editor"). If the board file does not expose them as states of an existing screen, record "no board frame, manual" and list it in the report as a design-doc gap. Do not invent a screen entry.
|
||||
|
||||
## Manual checks
|
||||
1. Keyboard: Tab order and visible focus on NoAccessState action; disabled row-menu items are reachable and announce their reason; View only mark has an accessible name and the tooltip opens on focus.
|
||||
2. Two real browsers, two roles: change a role through PUT /v1/user/{id}/role; the other browser's next refused action shows the toast and the page updates within 30 s of returning to the tab.
|
||||
3. As a Writer open /schema, /users, /settings/sites by link: the page-level state, URL unchanged.
|
||||
4. As a Contributor with content.update:events=own: another person's entry shows View only, Edit and Delete disabled with reasons; own entry editable.
|
||||
5. Media as a role with only media.view: no write control anywhere.
|
||||
6. French: all new copy translated. Dark mode and mobile for NoAccessState, OwnerOnlyTag, ReadOnlyBanner, ViewOnlyMark.
|
||||
7. `task lint:web` (eslint and vue-tsc) green; `task test:web` exit 0 on the final tree.
|
||||
8. The TestRouterRoutesAreInTheCatalogue Go failure is pre-existing and reported, not fixed.
|
||||
|
||||
## Backend items blocking or limiting this slice (for the coordinator)
|
||||
- B1 (done in this slice, signed-in callers only): `Article.CreatedBy` is `json:"-"`. GET /v1/article/, GET /v1/article/{id} and write responses must expose `created_by`. Privacy decision: anonymous callers should probably not get it.
|
||||
- B2 ("Created by" column of the Writer Articles frame): needs names for created_by ids; GET /v1/user/lookup?ids= exists and answers for roles with no grants, so it can serve, but the column is not part of this draft unless asked.
|
||||
- B3 (info): media has no owner; delete, merge and purge need scope all, so `own` on media.delete cannot be honoured in the UI.
|
||||
- B4 (info): entries already expose created_by; no backend change.
|
||||
- B5 (design gap): the design shows Publish and Status for entries, but the backend has no entry publish route.
|
||||
Reference in new issue
Block a user