feat: SPA Roles screens

This commit is contained in:
Florian Sylvain committed 2026-10-27 23:05:04 +01:00
1 parent 907949aa12
commit 3db490a615
47 files changed
+6947 -9

No files matched your search

+270
View File
@@ -0,0 +1,270 @@
Feature: SPA Roles screens (slice 6)
As an owner or a person who manages roles
I want to list, build, preview, delete and reset roles in the app
So that I can create a Contributor role in under a minute without leaving /settings
Background:
Given the backend contract of features/roles_permissions_roles_api.feature and roles_permissions_users_api.feature
And the spec of record is docs/design/screens/roles.md sections 1 to 4, 7 and 11, the tables "No-escalation feedback" and "HTTP status to copy", and routes.md "Roles and permissions"
And slice 5 is reused, not respecified: stores/permissions.ts (can, scopeOf), test/access.ts, NoAccessState, OwnerOnlyTag, ReadOnlyBanner, ViewOnlyMark, the meta.access route guard, and useApi 403/409 handling and refusalCopy
And a refusal is plain text: role_locked, own_role, role_built_in, built_in_name, role_name_taken, role_conflict, role_has_members, escalation (X-Escalation-Key), missing_permission (X-Missing-Permission), invalid_reassign, role_not_found
And GET /v1/role items carry id, name, description, built_in, locked, version, grants [{key, scope}], members, updated_at, updated_by {id, name} or null
And GET /v1/permission answers groups [{key, items: [{key, scope_kind, owner_only, per_type}]}] and gives the order and the kind of each control, human names come from permissionNames
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 design copy marked TBD is not asserted by a test; it is listed in the qa as a design gap
Rule: Routes, redirect and navigation
Scenario Outline: Route access
Given the session grants <grants>
When the person opens <route>
Then <result>
Examples:
| grants | route | result |
| roles.view | /settings/roles | the Roles list renders |
| roles.view | /settings/roles/new | the page-level 403 state, URL unchanged |
| roles.view, roles.manage | /settings/roles/new | the empty editor renders |
| roles.view | /settings/roles/4 | the editor renders (read-only without manage) |
| content.read=all | /settings/roles | the page-level 403 state "Your role, Contributor, doesn't include it..." with "Go to Articles" |
| content.read=all | /settings/roles/4 | the page-level 403 state |
Scenario: /settings/permissions is retired
When the person opens /settings/permissions
Then the router replaces it with /settings/roles
And no 403 state flashes
Scenario: The sidebar and drawer gain Roles
Given the session grants roles.view
Then Settings shows an "Access" group with "Roles" (shield icon) in the sidebar and the mobile drawer
And without roles.view the entry is absent in both
And there is no "Permissions" entry anywhere
Rule: Roles list (section 1)
Scenario: Default
Given GET /v1/role returns Owner (locked), Admin, Writer, Contributor (4 members), Content lead
Then the table shows Role, Description, Members, Type with a Locked badge on Owner, "Built-in" on the three built-ins and "Custom" on the rest
And the header reads "Roles" with "Who can do what on this site. Each user has one role." and an enabled "New role"
And on mobile each role is a card
Scenario: Loading, only built-ins, error
Then the first load shows skeleton rows after 150 ms and "New role" disabled
And when the list holds only the three built-ins it reads "No custom roles yet" / "The three built-in roles cover most sites. Create a role when someone needs something in between." with "New role"
And on a 500 or network failure it reads "Couldn't load roles" / "GET /v1/role failed. Check your connection and try again." with "Try again" that re-requests the list
Scenario: No permission to edit
Given the session grants roles.view and not roles.manage
Then "New role" is disabled with the tooltip "You can see roles, but only people who manage roles can create or change them."
And a banner reads "You can see roles but not change them" / "Ask an owner or admin if a role needs to change."
Scenario Outline: Row menu
Given the person <who> opens the menu on <role>
Then the items are Edit, Duplicate and Delete, and Delete is <delete>
Examples:
| who | role | delete |
| holds roles.manage | Contributor | enabled |
| is the owner | Writer | disabled, tooltip "Built-in roles can't be deleted. Duplicate it to make a custom role." on focus and hover |
| holds roles.manage | Owner | disabled (locked) |
| holds roles.view only | Contributor | Duplicate and Delete disabled, Edit opens the read-only editor |
Rule: Role editor (section 2)
Scenario: New role (empty)
Given the person holds roles.manage
When they open /settings/roles/new
Then nothing is granted, the title is "New role", Save changes is disabled with "Name your role to save it"
And the form shows Name ("For example, Contributor"), Description (optional, "What this role is for"), Content permissions with Quick presets Read only / Write own / Full content, the grid (Read, Create, Update, Delete, Publish) with "All content types", Articles and the other types, then General permissions with "Expand all"
Scenario Outline: The grid controls follow the catalogue
Then <cell> renders as <control>
And the keyboard and announcement contract of docs/design/components.md (roving tabindex, Home and End, a polite live region "Events, Update, set to Own.") holds
Examples:
| cell | control |
| Read, Update, Delete, Publish | a radiogroup named "{Type}: {Action}" with None, Own, All |
| Create | a switch named "{Type}: Create" with On and Off |
| General rows with scope_kind none-own-all | the same None / Own / All control |
| General boolean rows | a switch with a visible On/Off label |
| Schema row of the grid | a "Schema locked" tag and no control |
Scenario: Editing a custom role
Given GET /v1/role/4 returns Contributor, 4 members, grants content.read all, content.create, content.update own, content.update:case-studies all
Then Name and Description are filled, "4 members" and "Custom" show
And the Case studies row shows "Custom" with a "Reset" link
And "Reset" removes that type's typed grants and the row follows the "All content types" row again
Scenario: Unsaved changes
When three cells change
Then "3 unsaved changes" shows with Discard and Save changes, changed cells carry a square marker and the preview tags the changed line "Changed"
And Discard restores the loaded values
Scenario: Saving then saved
When Save changes is pressed with the current version
Then PUT /v1/role/4 {name, description, grants, version} is sent
And while in flight fields dim, Save shows the loader, the bar reads "Saving…" and Discard is disabled
And on 200 the bar closes, the form takes the answer (version +1) and a toast reads "Role saved" / "Contributor is updated for 4 members."
Scenario Outline: Creating
When the person saves a new role named "Contributor" with <grants>
Then POST /v1/role is sent with only those grants and the toast reads "Role created" / "Contributor is ready. Assign it from Users."
And the router moves to /settings/roles/<new id>
Examples:
| grants |
| nothing granted |
| the Write own preset |
Scenario Outline: Presets
When the person presses <preset>
Then every type row is set to <result>, the pressed preset has aria-pressed true, and unsaved changes are counted
Examples:
| preset | result |
| Read only | Read All, Create Off, Update/Delete/Publish None |
| Write own | Read All, Create On, Update Own, Delete None, Publish None |
| Full content | Read All, Create On, Update All, Delete All, Publish All |
Scenario Outline: Errors on save
When the server answers <status> <code> to the save
Then <effect>
And the form keeps the person's edits
Examples:
| status | code | effect |
| (none) | empty name | nothing is sent; field error "Give the role a name." and "1 problem to fix" |
| 400 | invalid_role_name | the same "Give the role a name." |
| 409 | role_name_taken | field error 'A role named "Writer" already exists. Choose a different name.' and "1 problem to fix" |
| 409 | role_conflict | the Conflict state |
| 403 | missing_permission (roles.manage) | the Permission lowered state |
| 403 | escalation | the "You can't grant that" / "You can only grant what your role has." message, X-Escalation-Key names the row |
| 403 | role_locked | the screen shows "This role is locked" and the editor turns read-only |
| 403 | own_role | the screen shows "You can't change your own role" / "Ask another owner or admin." |
| 400 | built_in_name | "Built-in roles keep their name." |
Scenario: Conflict
Given Contributor was loaded at version 2 and the server answers 409 role_conflict
When the SPA re-reads GET /v1/role/4 (updated_by "Jonas Weber", updated_at 11:02)
Then it reads "This role changed while you were editing" / "Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits."
And "Reload role" replaces the form with the server role, "Review differences" is offered (copy TBD), "2 unsaved changes. Save is off until you reload" shows, and Save is disabled until a reload
Scenario: Permission lowered while open (403)
When a 403 missing_permission answers the save or a refresh
Then the page becomes read-only, "Your access changed" / "An owner changed your role while this page was open. You can still see roles, but you can't change them. Your edits weren't saved." with "Reload page"
And "Reload page" refreshes the session and the role
Scenario Outline: Read-only editor
Given the person <who> opens <role>
Then <result>
And the owner-only gate is can("roles.edit_owner_only") on a built-in role, never a role name
Examples:
| who | role | result |
| is the owner | Writer, 5 members | the warning "This role has 5 members" / "Changes apply to all of them when you save, and to the API keys they created. Built-in roles can be restored with Reset to default." and "Built-in" |
| is an admin | Writer | everything disabled, "Only the owner can edit built-in roles" / "You can read what Writer allows. To change it, duplicate it into a custom role." with "Duplicate role" |
| holds roles.view only | Contributor | fields locked, ReadOnlyBanner, no Save bar |
| holds roles.manage | their own role | read-only, "You can't change the permissions of your own role. Ask another owner or admin." |
| anyone with roles.view | Owner | "The Owner role is locked" / "It always has every permission. To hand it to someone else, transfer ownership from Users." with "Go to Users" and a Locked badge |
Scenario: General permissions
Then groups are collapsible from GET /v1/permission (Activity, Users and invites, Settings, Roles, System, Schema, Media, Authors), each reads "x of y enabled", for example "2 of 2 enabled"
And rows show the names and descriptions of the design, for example "See activity" / "Read the log of who did what."
And "Expand all" opens every group
Scenario: Owner-only items
Given the person is an admin
Then Delete a site, Edit roles with owner-only power, Apply software updates, Edit activity retention and Transfer ownership are disabled, off, with a lock and the "Owner only" tag (OwnerOnlyTag)
And no request ever sends an owner-only key on a non-Owner role
Scenario: Escalation locks
Given the session grants content.publish=own, roles.manage
Then in the editor Publish "All" is disabled with a lock, described by "You can only grant what your role has. Your role can publish only entries you created, so All isn't available."
And the popover opens on focus and on hover and "None" and "Own" stay available
And a typed override in the session (for example content.update:article=none) locks the matching cell the same way
Rule: Plain-language preview (section 3)
Scenario: Live panel
When Update changes from Own to All on a Contributor with 4 members
Then the card "What this role can do" / "Updates as you edit." shows Content ("All content types" with its sentence) and Everything else lines, a last line "No access to Activity, Users and invites, Settings, Roles." and "Applies to 4 members, and to the API keys they created."
And the changed line carries "Changed"
And sentences come from fixed templates, never from keys
Scenario Outline: Preview variants
Then <case> shows <copy>
Examples:
| case | copy |
| Contributor sentence | "Can read any entry, create entries and edit only their own. Can't delete and publish." |
| read-only built-in role | "A summary of what this role allows." and no "Updates as you edit." |
| new role, nothing granted | "No access to entries." and "No access to Schema, Media, Authors, Activity, Users and invites, Settings, Roles." |
| types sharing permissions | one line per group of types |
Scenario: Mobile sheet
Given a 390 px viewport
When "What this role can do" in the sticky bar is tapped
Then a 92% bottom sheet opens with a grabber and "Close", and the grid is one card per type
Rule: Delete and reassign (section 4)
Scenario: Blocked: has members
When Delete is chosen on Contributor (4 members)
Then the dialog "Delete Contributor?" reads "4 members have this role. Move them to another role first. Then Contributor can be deleted."
And "Move members to" offers "Choose a role", "They keep their API keys. Keys follow the new role.", and "Move 4 members and delete role" stays disabled until a role is chosen
Scenario Outline: Role picker
Then <role> is <state>
Examples:
| role | state |
| Owner | not offered (one owner) |
| Contributor | not offered (the source) |
| Writer | enabled with its summary |
| Content lead | enabled for an owner; for a caller who cannot cover it, disabled with "Your role can't assign a role with more power than yours." |
| Admin | the same rule |
Scenario: Move then delete
When the person picks Writer and confirms
Then POST /v1/role/4/reassign {target_role_id} is sent, then DELETE /v1/role/4
And on both 2xx the dialog closes and the row disappears; the failure of either keeps the dialog open with the refusal copy
Scenario Outline: Delete answers
Then <code> shows <copy>
Examples:
| code | copy |
| role_has_members | the Blocked state with the live count (a member joined meanwhile) |
| role_locked | "This role is locked" / "The Owner role can't be changed or deleted. Transfer ownership from Users instead." |
| role_built_in | "Built-in roles can't be deleted" / "Duplicate it to make a custom role." |
| escalation | "You can't grant that" / "You can only grant what your role has." |
| role_not_found | the list refreshes and the row is gone |
Scenario: Confirm: no members
When Delete is chosen on Intern (0 members)
Then "Delete Intern?" / "No one has this role. This can't be undone." / "Deleting a role doesn't affect content." with Cancel and "Delete role"; DELETE /v1/role/{id} only, no reassign call
Scenario: Built-in role dialog (component only)
Then the dialog for Writer reads "Delete Writer?" / "Built-in roles can't be deleted." / "Writer is built in" / "Duplicate it to make a custom role you can change or delete." with "Duplicate role" and "Close"
And it is not reachable by URL or menu; the spec is at component level
Rule: Reset to default
Scenario: Owner resets a built-in role
Given the owner opens an edited Writer
When they confirm the reset (copy TBD)
Then POST /v1/role/{id}/reset {version} is sent, the form takes the seeded grants and version +1, and a toast confirms (copy TBD)
Scenario Outline: Reset visibility and answers
Then <case>
Examples:
| case |
| the control is absent for a custom role, for the Owner role, and without roles.edit_owner_only |
| 409 role_conflict shows the Conflict state |
| 403 role_locked / missing_permission use the shared refusal copy |
Rule: Duplicate
Scenario: Duplicate a role
When Duplicate is chosen on Writer
Then the editor opens at /settings/roles/new prefilled with Writer's grants, an empty name and a note that nothing is saved yet (copy TBD)
And saving sends POST /v1/role with no built_in or locked field
Rule: Create a Contributor in under a minute (section 11)
Scenario: The six steps
Given the person holds roles.manage
When they press New role, type "Contributor" and "Writes posts and edits only their own", press Write own and Save changes
Then POST /v1/role carries name, description and the Write own grants
And the toast "Role created" / "Contributor is ready. Assign it from Users." shows, the bar closes and the role is in the list and in the invite picker
+52
View File
@@ -0,0 +1,52 @@
# QA procedure: SPA Roles screens (slice 6)
Spec: features/roles_spa_screens.feature. Vitest + Vue Test Utils under `task test:web`, /v1 mocked through the existing kits, access bodies built with web/src/test/access.ts. No live server, no real SMTP. Visual checks are out of this slice by user decision.
## Honesty rules
0. First run `task test:web` on the unmodified tree and record the baseline (exit code, failing specs).
1. If the runner cannot execute, every SPA item is NOT RUN, never passing.
2. Report each new or edited spec and which already failed in the baseline.
3. Spec only the copy the design gives. Items marked TBD in the feature (reset, review differences, duplicate note, types without schema.view, templates beyond the examples, role_not_found) get behaviour specs that don't assert text, and are listed as design gaps.
4. Date and clock: fixed `updated_at` strings, a pinned time zone or injected formatter for "11:02"; no spec freezes the system date.
## Automated (Vitest)
| Spec | Cases |
|---|---|
| router/__tests__/rolesRoutes.spec.ts (new) | the route table of the feature; /settings/permissions redirect with no 403 flash; typed guard with the session checking |
| components/__tests__/DashboardShell.spec.ts (extend) | Access > Roles entry with roles.view in sidebar and drawer, absent without it, no Permissions entry |
| composables/__tests__/useRoles.spec.ts (new) | list, get, create, update, delete, reassign, reset calls; error mapping of every refusal code; role_conflict re-read of updated_by |
| lib/__tests__/rolePreview.spec.ts (new) | every sentence of the design, grouping of types, Nothing granted, Changed tagging, read-only wording, members line |
| lib/__tests__/roleForm.spec.ts (new) | form from role, grants to payload (wildcard plus typed, none omitted, owner-only never sent), Reset of a Custom row, presets, unsaved counter, discard |
| lib/__tests__/grantLocks.spec.ts (new) | escalation locks via scopeOf (all vs own, typed none), owner-only items, picker disabling |
| views/__tests__/RolesListView.spec.ts (new) | Default, Loading (150 ms with fake timers), Only built-in, Error with retry, No permission to edit, row menus, mobile cards |
| views/__tests__/RoleEditorView.spec.ts (new) | New role, Editing, Built-in as owner / admin, Owner locked, own role, view-only, Unsaved, Saving, Saved, name required / taken, Conflict, 403 lowered, escalation and role_locked answers, create then route replace, duplicate prefill |
| components/__tests__/PermissionGrid.spec.ts (covers PresetPills and OnOffCell), ScopeControl.spec.ts, PermissionGroup.spec.ts (new) | roles/names ("Events: Update"), keyboard (arrows, Home, End, Space, roving tabindex), live region text, aria-disabled and reason, changed marker, "x of y enabled", expand all |
| components/__tests__/RolePreview.spec.ts (new) | card on desktop, 92% sheet on mobile, Close |
| components/__tests__/DeleteRoleDialog.spec.ts (covers RolePicker through pickerKit.ts) (new) | Blocked, picker disabled reasons, Move and delete sequence and each failure, Confirm no members, built-in dialog, role_has_members live count |
| lib/__tests__/grantKey.spec.ts (new) | parseGrantKey and formatGrantKey split on the first colon, type key containing a colon through form, locks and names |
| i18n/roleMessages.spec.ts (new) | new keys exist in EN and FR, placeholders match |
## Manual checks
1. Keyboard: tab order across the grid (one stop per cell), arrows, Home and End; disabled items reach focus and announce their reason; focus visible.
2. Screen reader: live region announces "Events, Update, set to Own."; sticky bar and sheet focus return.
3. Two browsers: owner edits Contributor while an admin has it open, then the admin's Save gets the Conflict state with the real name and time.
4. Lower a role while its editor is open in another tab: the next refused save shows "Your access changed".
5. Real flow in a real browser: create Contributor with Write own, assign it from Users, delete Intern, delete Contributor with reassign.
6. Mobile 390 px: card grid, sticky bar and sheet; touch targets 44 px. French copy. Dark mode.
7. Direct link to /settings/permissions after build: the embedded SPA redirects (a Go 301 is not implemented).
8. `task lint:web` (eslint and vue-tsc) green; `task test:web` exit 0 on the final tree.
## Design gaps and backend notes
- G1 presets semantics and the "7 unsaved changes" count: a preset touches only the content grid and the count is the number of changed (key, scope) entries, name and description counting 1 each.
- G2 "All content types" is the wildcard `content.<action>`; a type row is Custom with a Reset link when it holds a typed grant, including `:none`.
- G3 type list without schema.view: the grid shows Articles plus the types named in the role's typed grants, with a note; copy TBD.
- G4 reset-to-default button, confirm dialog and toast copy TBD.
- G5 "Review differences" read-only Added/Removed/Changed list; copy TBD.
- G6 Duplicate at `/settings/roles/new?from=<id>`, query stripped after load; copy TBD.
- G7 preview templates beyond the Contributor, Nothing granted and Schema/Media/Authors examples; copy TBD.
- G8 `role_not_found` page-level state with "Back to Roles"; copy TBD. `invalid_role_name` maps to "Give the role a name."; other 400 codes show a generic error toast.
- G9 the SPA cannot send an HTTP 301: a vue-router client redirect to `/settings/roles`.
- G10 the built-in delete dialog is unreachable: component-level spec only.
- G11 no leave guard: leaving drops the edits.
- G12 after create the route is replaced with `/settings/roles/:id` and the "Role created" toast shows.
- No backend change is needed: all routes exist (including POST /v1/role/{id}/reset and updated_by).