feat: SPA Roles screens
This commit is contained in:
1 parent
907949aa12
commit
3db490a615
47 files changed
+6947
-9
No files matched your search
@@ -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
|
||||
@@ -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).
|
||||
Reference in new issue
Block a user