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).
|
||||||
@@ -105,9 +105,20 @@ const mediaSettingsChildren = computed(() => [
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const accessChildren = computed(() => [
|
||||||
|
{ label: t('Access'), type: 'label' as const },
|
||||||
|
{
|
||||||
|
label: t('Roles'),
|
||||||
|
to: '/settings/roles',
|
||||||
|
icon: 'i-lucide-shield',
|
||||||
|
active: !props.noCurrentNav && route.path.startsWith('/settings/roles'),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
const settingsChildren = computed(() => [
|
const settingsChildren = computed(() => [
|
||||||
...(permissions.can('settings.view') ? mediaSettingsChildren.value : []),
|
...(permissions.can('settings.view') ? mediaSettingsChildren.value : []),
|
||||||
...(permissions.can('settings.manage_sites') ? publishingChildren.value : []),
|
...(permissions.can('settings.manage_sites') ? publishingChildren.value : []),
|
||||||
|
...(permissions.can('roles.view') ? accessChildren.value : []),
|
||||||
])
|
])
|
||||||
|
|
||||||
const schemaItem = computed(() =>
|
const schemaItem = computed(() =>
|
||||||
@@ -191,7 +202,10 @@ const navItems = computed(() =>
|
|||||||
to: '/settings',
|
to: '/settings',
|
||||||
icon: 'i-lucide-settings',
|
icon: 'i-lucide-settings',
|
||||||
slot: 'settings',
|
slot: 'settings',
|
||||||
show: permissions.can('settings.view') || permissions.can('settings.manage_sites'),
|
show:
|
||||||
|
permissions.can('settings.view') ||
|
||||||
|
permissions.can('settings.manage_sites') ||
|
||||||
|
permissions.can('roles.view'),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
.filter((item) => item.show)
|
.filter((item) => item.show)
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import DirectoryDialog from '@/components/DirectoryDialog.vue'
|
||||||
|
import RolePicker from '@/components/RolePicker.vue'
|
||||||
|
import { ApiError } from '@/composables/useApi'
|
||||||
|
import { useRoles } from '@/composables/useRoles'
|
||||||
|
import { refusalCopy } from '@/lib/permissionCopy'
|
||||||
|
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||||
|
import type { RoleItem } from '@/lib/roles'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
role: RoleItem
|
||||||
|
roles: RoleItem[]
|
||||||
|
canAssign: (role: RoleItem) => boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
deleted: [role: RoleItem]
|
||||||
|
gone: [role: RoleItem]
|
||||||
|
duplicate: [role: RoleItem]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const open = defineModel<boolean>('open', { default: true })
|
||||||
|
|
||||||
|
const { reassignRole, deleteRole } = useRoles()
|
||||||
|
|
||||||
|
const target = ref<number | undefined>(undefined)
|
||||||
|
const pending = ref(false)
|
||||||
|
const notice = ref<DirectoryNotice | null>(null)
|
||||||
|
const members = ref(props.role.members)
|
||||||
|
|
||||||
|
const stage = computed<'builtin' | 'blocked' | 'confirm'>(() => {
|
||||||
|
if (props.role.built_in) {
|
||||||
|
return 'builtin'
|
||||||
|
}
|
||||||
|
return members.value > 0 ? 'blocked' : 'confirm'
|
||||||
|
})
|
||||||
|
|
||||||
|
const title = computed(() => t('Delete {0}?', [props.role.name]))
|
||||||
|
|
||||||
|
const description = computed(() => {
|
||||||
|
switch (stage.value) {
|
||||||
|
case 'builtin':
|
||||||
|
return t("Built-in roles can't be deleted.")
|
||||||
|
case 'blocked':
|
||||||
|
return t(
|
||||||
|
'{0} members have this role. Move them to another role first. Then {1} can be deleted.',
|
||||||
|
[members.value, props.role.name],
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return t("No one has this role. This can't be undone.")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const confirmLabel = computed(() => {
|
||||||
|
if (stage.value === 'builtin') {
|
||||||
|
return t('Duplicate role')
|
||||||
|
}
|
||||||
|
return stage.value === 'blocked'
|
||||||
|
? t('Move {0} members and delete role', [members.value])
|
||||||
|
: t('Delete role')
|
||||||
|
})
|
||||||
|
|
||||||
|
const pendingLabel = computed(() =>
|
||||||
|
stage.value === 'blocked' ? t('Moving and deleting…') : t('Deleting…'),
|
||||||
|
)
|
||||||
|
|
||||||
|
function showRefusal(error: unknown) {
|
||||||
|
if (!(error instanceof ApiError)) {
|
||||||
|
notice.value = { description: t("Couldn't delete the role. Try again in a moment.") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (error.code === 'role_has_members') {
|
||||||
|
members.value = Math.max(members.value, 1)
|
||||||
|
target.value = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (error.code === 'role_not_found') {
|
||||||
|
emit('gone', props.role)
|
||||||
|
open.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const copy = error.code === undefined ? undefined : refusalCopy(error.code, error.permission)
|
||||||
|
notice.value =
|
||||||
|
copy === undefined
|
||||||
|
? { description: t("Couldn't delete the role. Try again in a moment.") }
|
||||||
|
: { title: copy.title, description: copy.description ?? '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onConfirm() {
|
||||||
|
if (stage.value === 'builtin') {
|
||||||
|
emit('duplicate', props.role)
|
||||||
|
open.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (stage.value === 'blocked' && target.value === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pending.value = true
|
||||||
|
notice.value = null
|
||||||
|
try {
|
||||||
|
if (stage.value === 'blocked' && target.value !== undefined) {
|
||||||
|
await reassignRole(props.role.id, target.value)
|
||||||
|
members.value = 0
|
||||||
|
}
|
||||||
|
await deleteRole(props.role.id)
|
||||||
|
emit('deleted', props.role)
|
||||||
|
open.value = false
|
||||||
|
} catch (error) {
|
||||||
|
showRefusal(error)
|
||||||
|
} finally {
|
||||||
|
pending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DirectoryDialog
|
||||||
|
v-model:open="open"
|
||||||
|
:title="title"
|
||||||
|
:description="description"
|
||||||
|
:confirm-label="confirmLabel"
|
||||||
|
:pending-label="pendingLabel"
|
||||||
|
:cancel-label="stage === 'builtin' ? t('Close') : t('Cancel')"
|
||||||
|
:tone="stage === 'builtin' ? 'neutral' : 'danger'"
|
||||||
|
:pending="pending"
|
||||||
|
:notice="notice"
|
||||||
|
:confirm-disabled="stage === 'blocked' && target === undefined"
|
||||||
|
@confirm="onConfirm"
|
||||||
|
>
|
||||||
|
<template v-if="stage === 'blocked'">
|
||||||
|
<UFormField
|
||||||
|
:label="t('Move members to')"
|
||||||
|
:help="t('They keep their API keys. Keys follow the new role.')"
|
||||||
|
>
|
||||||
|
<RolePicker
|
||||||
|
v-model:selected="target"
|
||||||
|
:roles="roles"
|
||||||
|
:source-id="role.id"
|
||||||
|
:can-assign="canAssign"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</template>
|
||||||
|
<p v-else-if="stage === 'builtin'" class="flex flex-col gap-1 text-muted">
|
||||||
|
<span class="font-medium text-highlighted">{{
|
||||||
|
t('{0} is built in', [role.name])
|
||||||
|
}}</span>
|
||||||
|
<span>{{ t('Duplicate it to make a custom role you can change or delete.') }}</span>
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-muted">{{ t("Deleting a role doesn't affect content.") }}</p>
|
||||||
|
</DirectoryDialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { useId } from 'vue'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
modelValue: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
reason?: string
|
||||||
|
changed?: boolean
|
||||||
|
}>(),
|
||||||
|
{ disabled: false, reason: undefined, changed: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||||
|
|
||||||
|
const uid = useId()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="relative inline-flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
v-if="changed"
|
||||||
|
data-testid="changed-marker"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="absolute -top-1 -left-1 z-10 size-1.5 bg-primary"
|
||||||
|
/>
|
||||||
|
<UIcon v-if="reason" name="i-lucide-lock" class="size-3 text-muted" />
|
||||||
|
<USwitch
|
||||||
|
:model-value="modelValue"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-label="label"
|
||||||
|
:aria-describedby="reason ? `${uid}-reason` : undefined"
|
||||||
|
:label="modelValue ? t('On') : t('Off')"
|
||||||
|
size="sm"
|
||||||
|
:ui="{ label: 'text-xs text-muted' }"
|
||||||
|
@update:model-value="$emit('update:modelValue', Boolean($event))"
|
||||||
|
/>
|
||||||
|
<span v-if="reason" :id="`${uid}-reason`" class="sr-only">{{ reason }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import OnOffCell from '@/components/OnOffCell.vue'
|
||||||
|
import ScopeControl from '@/components/ScopeControl.vue'
|
||||||
|
import { lockReason, lockedScopes, type CallerScope } from '@/lib/grantLocks'
|
||||||
|
import {
|
||||||
|
CONTENT_ACTIONS,
|
||||||
|
contentKey,
|
||||||
|
isCellChanged,
|
||||||
|
isCustomRow,
|
||||||
|
scopeAt,
|
||||||
|
type ContentAction,
|
||||||
|
type GrantMap,
|
||||||
|
} from '@/lib/roleForm'
|
||||||
|
import type { Scope } from '@/lib/permissions'
|
||||||
|
|
||||||
|
export interface GridType {
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
builtin?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridChange {
|
||||||
|
action: ContentAction
|
||||||
|
scope: Scope
|
||||||
|
typeKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
types: GridType[]
|
||||||
|
grants: GrantMap
|
||||||
|
initial: GrantMap
|
||||||
|
callerScope: CallerScope
|
||||||
|
readOnly?: boolean
|
||||||
|
mobile?: boolean
|
||||||
|
}>(),
|
||||||
|
{ readOnly: false, mobile: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{ change: [change: GridChange]; reset: [typeKey: string] }>()
|
||||||
|
|
||||||
|
const announcement = ref('')
|
||||||
|
|
||||||
|
const columns = computed(() =>
|
||||||
|
CONTENT_ACTIONS.map((action) => ({ action, label: actionLabel(action) })),
|
||||||
|
)
|
||||||
|
|
||||||
|
const rows = computed(() => [
|
||||||
|
{ typeKey: undefined as string | undefined, name: t('All content types') },
|
||||||
|
...props.types.map((type) => ({ typeKey: type.key as string | undefined, name: type.name })),
|
||||||
|
])
|
||||||
|
|
||||||
|
function actionLabel(action: ContentAction) {
|
||||||
|
switch (action) {
|
||||||
|
case 'read':
|
||||||
|
return t('Read')
|
||||||
|
case 'create':
|
||||||
|
return t('Create')
|
||||||
|
case 'update':
|
||||||
|
return t('Update')
|
||||||
|
case 'delete':
|
||||||
|
return t('Delete')
|
||||||
|
case 'publish':
|
||||||
|
return t('Publish')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeLabel(scope: Scope) {
|
||||||
|
switch (scope) {
|
||||||
|
case 'none':
|
||||||
|
return t('None')
|
||||||
|
case 'own':
|
||||||
|
return t('Own')
|
||||||
|
case 'all':
|
||||||
|
return t('All')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueOf(action: ContentAction, typeKey?: string): Scope {
|
||||||
|
return scopeAt(props.grants, contentKey(action), typeKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
function locksOf(action: ContentAction, typeKey?: string) {
|
||||||
|
const key = contentKey(action)
|
||||||
|
const caller = props.callerScope(key, typeKey === undefined ? {} : { typeKey })
|
||||||
|
const locks: Partial<Record<Scope, string>> = {}
|
||||||
|
for (const scope of lockedScopes(caller)) {
|
||||||
|
locks[scope] = lockReason(key, caller)
|
||||||
|
}
|
||||||
|
return locks
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLock(typeKey?: string) {
|
||||||
|
return locksOf('create', typeKey).all
|
||||||
|
}
|
||||||
|
|
||||||
|
function changedOf(action: ContentAction, typeKey?: string) {
|
||||||
|
return isCellChanged(props.grants, props.initial, contentKey(action), typeKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nameOf(action: ContentAction, rowName: string) {
|
||||||
|
return `${rowName}: ${actionLabel(action)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function commit(action: ContentAction, rowName: string, scope: Scope, typeKey?: string) {
|
||||||
|
announcement.value = t('{0}, {1}, set to {2}.', [
|
||||||
|
rowName,
|
||||||
|
actionLabel(action),
|
||||||
|
action === 'create' ? (scope === 'all' ? t('On') : t('Off')) : scopeLabel(scope),
|
||||||
|
])
|
||||||
|
emit('change', { action, scope, typeKey })
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCustom(typeKey?: string) {
|
||||||
|
return typeKey !== undefined && isCustomRow(props.grants, typeKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeOf(typeKey?: string) {
|
||||||
|
return props.types.find((type) => type.key === typeKey)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :role="mobile ? undefined : 'grid'" :aria-label="t('Content permissions')">
|
||||||
|
<p class="sr-only" role="status" aria-live="polite" data-testid="grid-live-region">
|
||||||
|
{{ announcement }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-if="!mobile">
|
||||||
|
<div
|
||||||
|
role="row"
|
||||||
|
class="grid grid-cols-[minmax(0,1.6fr)_repeat(5,minmax(0,1fr))] items-center gap-3 border-b border-default bg-elevated px-3 py-2 text-sm font-medium text-muted"
|
||||||
|
>
|
||||||
|
<span role="columnheader">{{ t('Content type') }}</span>
|
||||||
|
<span v-for="column in columns" :key="column.action" role="columnheader">{{
|
||||||
|
column.label
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="row in rows"
|
||||||
|
:key="row.typeKey ?? 'all'"
|
||||||
|
role="row"
|
||||||
|
:data-testid="row.typeKey === undefined ? 'row-all' : `row-${row.typeKey}`"
|
||||||
|
class="grid grid-cols-[minmax(0,1.6fr)_repeat(5,minmax(0,1fr))] items-center gap-3 border-b border-default px-3 py-2"
|
||||||
|
>
|
||||||
|
<span role="rowheader" class="flex flex-col gap-0.5">
|
||||||
|
<span class="font-medium text-highlighted">{{ row.name }}</span>
|
||||||
|
<span v-if="row.typeKey === undefined" class="text-xs text-muted">{{
|
||||||
|
t('Sets every row')
|
||||||
|
}}</span>
|
||||||
|
<span v-else class="flex flex-wrap items-center gap-1.5 text-xs text-muted">
|
||||||
|
<template v-if="typeOf(row.typeKey)?.builtin">
|
||||||
|
<UBadge color="neutral" variant="subtle" :label="t('Built-in')" />
|
||||||
|
<UBadge color="neutral" variant="subtle" :label="t('Schema locked')" />
|
||||||
|
</template>
|
||||||
|
<template v-if="isCustom(row.typeKey)">
|
||||||
|
<UBadge color="neutral" variant="outline" :label="t('Custom')" />
|
||||||
|
<UButton
|
||||||
|
variant="link"
|
||||||
|
color="neutral"
|
||||||
|
size="xs"
|
||||||
|
:disabled="readOnly"
|
||||||
|
class="p-0 text-xs"
|
||||||
|
@click="emit('reset', row.typeKey!)"
|
||||||
|
>
|
||||||
|
{{ t('Reset') }}
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span v-for="column in columns" :key="column.action" role="gridcell">
|
||||||
|
<OnOffCell
|
||||||
|
v-if="column.action === 'create'"
|
||||||
|
:label="nameOf('create', row.name)"
|
||||||
|
:model-value="valueOf('create', row.typeKey) !== 'none'"
|
||||||
|
:disabled="readOnly || createLock(row.typeKey) !== undefined"
|
||||||
|
:reason="createLock(row.typeKey)"
|
||||||
|
:changed="changedOf('create', row.typeKey)"
|
||||||
|
@update:model-value="
|
||||||
|
commit('create', row.name, $event ? 'all' : 'none', row.typeKey)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<ScopeControl
|
||||||
|
v-else
|
||||||
|
:label="nameOf(column.action, row.name)"
|
||||||
|
:model-value="valueOf(column.action, row.typeKey)"
|
||||||
|
:disabled="readOnly"
|
||||||
|
:locks="locksOf(column.action, row.typeKey)"
|
||||||
|
:changed="changedOf(column.action, row.typeKey)"
|
||||||
|
@update:model-value="commit(column.action, row.name, $event, row.typeKey)"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="flex flex-col gap-3">
|
||||||
|
<div
|
||||||
|
v-for="row in rows"
|
||||||
|
:key="row.typeKey ?? 'all'"
|
||||||
|
:data-testid="row.typeKey === undefined ? 'card-all' : `card-${row.typeKey}`"
|
||||||
|
class="flex flex-col gap-3 border border-default bg-default p-3"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="font-medium text-highlighted">{{ row.name }}</span>
|
||||||
|
<span v-if="row.typeKey === undefined" class="text-xs text-muted">{{
|
||||||
|
t('Sets every row')
|
||||||
|
}}</span>
|
||||||
|
<UBadge
|
||||||
|
v-if="typeOf(row.typeKey)?.builtin"
|
||||||
|
color="neutral"
|
||||||
|
variant="subtle"
|
||||||
|
:label="t('Built-in')"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
v-if="isCustom(row.typeKey)"
|
||||||
|
variant="link"
|
||||||
|
color="neutral"
|
||||||
|
size="xs"
|
||||||
|
:disabled="readOnly"
|
||||||
|
class="p-0 text-xs"
|
||||||
|
@click="emit('reset', row.typeKey!)"
|
||||||
|
>
|
||||||
|
{{ t('Reset') }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="column in columns"
|
||||||
|
:key="column.action"
|
||||||
|
class="flex items-center justify-between gap-3"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-muted">{{ column.label }}</span>
|
||||||
|
<OnOffCell
|
||||||
|
v-if="column.action === 'create'"
|
||||||
|
:label="nameOf('create', row.name)"
|
||||||
|
:model-value="valueOf('create', row.typeKey) !== 'none'"
|
||||||
|
:disabled="readOnly || createLock(row.typeKey) !== undefined"
|
||||||
|
:reason="createLock(row.typeKey)"
|
||||||
|
:changed="changedOf('create', row.typeKey)"
|
||||||
|
@update:model-value="
|
||||||
|
commit('create', row.name, $event ? 'all' : 'none', row.typeKey)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<ScopeControl
|
||||||
|
v-else
|
||||||
|
:label="nameOf(column.action, row.name)"
|
||||||
|
:model-value="valueOf(column.action, row.typeKey)"
|
||||||
|
:disabled="readOnly"
|
||||||
|
:locks="locksOf(column.action, row.typeKey)"
|
||||||
|
:changed="changedOf(column.action, row.typeKey)"
|
||||||
|
@update:model-value="commit(column.action, row.name, $event, row.typeKey)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import OnOffCell from '@/components/OnOffCell.vue'
|
||||||
|
import OwnerOnlyTag from '@/components/OwnerOnlyTag.vue'
|
||||||
|
import ScopeControl from '@/components/ScopeControl.vue'
|
||||||
|
import { lockReason, lockedScopes, type CallerScope } from '@/lib/grantLocks'
|
||||||
|
import { permissionDescription, permissionName } from '@/lib/permissionNames'
|
||||||
|
import { isCellChanged, scopeAt, type GrantMap } from '@/lib/roleForm'
|
||||||
|
import type { Scope } from '@/lib/permissions'
|
||||||
|
import type { PermissionGroupItem } from '@/lib/roles'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
group: PermissionGroupItem
|
||||||
|
grants: GrantMap
|
||||||
|
initial: GrantMap
|
||||||
|
callerScope: CallerScope
|
||||||
|
readOnly?: boolean
|
||||||
|
}>(),
|
||||||
|
{ readOnly: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{ change: [change: { key: string; scope: Scope }] }>()
|
||||||
|
|
||||||
|
const open = defineModel<boolean>('open', { default: false })
|
||||||
|
const announcement = ref('')
|
||||||
|
|
||||||
|
const GROUP_LABELS: Record<string, () => string> = {
|
||||||
|
activity: () => t('Activity'),
|
||||||
|
users: () => t('Users and invites'),
|
||||||
|
settings: () => t('Settings'),
|
||||||
|
roles: () => t('Roles'),
|
||||||
|
system: () => t('System'),
|
||||||
|
schema: () => t('Schema'),
|
||||||
|
media: () => t('Media'),
|
||||||
|
authors: () => t('Authors'),
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = computed(() => GROUP_LABELS[props.group.key]?.() ?? props.group.key)
|
||||||
|
const enabled = computed(
|
||||||
|
() => props.group.items.filter((item) => scopeAt(props.grants, item.key) !== 'none').length,
|
||||||
|
)
|
||||||
|
|
||||||
|
function scopeLabel(scope: Scope) {
|
||||||
|
return scope === 'none' ? t('None') : scope === 'own' ? t('Own') : t('All')
|
||||||
|
}
|
||||||
|
|
||||||
|
function locksOf(key: string) {
|
||||||
|
const caller = props.callerScope(key)
|
||||||
|
const locks: Partial<Record<Scope, string>> = {}
|
||||||
|
for (const scope of lockedScopes(caller)) {
|
||||||
|
locks[scope] = lockReason(key, caller)
|
||||||
|
}
|
||||||
|
return locks
|
||||||
|
}
|
||||||
|
|
||||||
|
function commit(key: string, scope: Scope, toggle: boolean) {
|
||||||
|
announcement.value = t('{0}, set to {1}.', [
|
||||||
|
permissionName(key) ?? key,
|
||||||
|
toggle ? (scope === 'all' ? t('On') : t('Off')) : scopeLabel(scope),
|
||||||
|
])
|
||||||
|
emit('change', { key, scope })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UCollapsible v-model:open="open" :data-testid="`group-${group.key}`">
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
block
|
||||||
|
trailing-icon="i-lucide-chevron-down"
|
||||||
|
class="h-11 justify-between rounded-none border-b border-default px-3 md:h-10"
|
||||||
|
:ui="{ trailingIcon: open ? 'rotate-180 size-4' : 'size-4' }"
|
||||||
|
>
|
||||||
|
<span class="font-medium text-highlighted">{{ label }}</span>
|
||||||
|
<span class="text-xs text-muted">{{
|
||||||
|
t('{0} of {1} enabled', [enabled, group.items.length])
|
||||||
|
}}</span>
|
||||||
|
</UButton>
|
||||||
|
<template #content>
|
||||||
|
<p class="sr-only" role="status" aria-live="polite">{{ announcement }}</p>
|
||||||
|
<ul class="flex flex-col">
|
||||||
|
<li
|
||||||
|
v-for="item in group.items"
|
||||||
|
:key="item.key"
|
||||||
|
:data-testid="`permission-${item.key}`"
|
||||||
|
class="flex flex-wrap items-center justify-between gap-3 border-b border-default px-3 py-2.5"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span class="flex items-center gap-2 font-medium text-highlighted">
|
||||||
|
{{ permissionName(item.key) }}
|
||||||
|
<OwnerOnlyTag v-if="item.owner_only" />
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-muted">{{
|
||||||
|
permissionDescription(item.key)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<OnOffCell
|
||||||
|
v-if="item.scope_kind === 'toggle'"
|
||||||
|
:label="permissionName(item.key) ?? item.key"
|
||||||
|
:model-value="scopeAt(grants, item.key) !== 'none'"
|
||||||
|
:disabled="
|
||||||
|
readOnly || item.owner_only || locksOf(item.key).all !== undefined
|
||||||
|
"
|
||||||
|
:reason="
|
||||||
|
item.owner_only
|
||||||
|
? t('Only the owner can grant this.')
|
||||||
|
: locksOf(item.key).all
|
||||||
|
"
|
||||||
|
:changed="isCellChanged(grants, initial, item.key)"
|
||||||
|
@update:model-value="commit(item.key, $event ? 'all' : 'none', true)"
|
||||||
|
/>
|
||||||
|
<ScopeControl
|
||||||
|
v-else
|
||||||
|
:label="permissionName(item.key) ?? item.key"
|
||||||
|
:model-value="scopeAt(grants, item.key)"
|
||||||
|
:disabled="readOnly || item.owner_only"
|
||||||
|
:locks="locksOf(item.key)"
|
||||||
|
:changed="isCellChanged(grants, initial, item.key)"
|
||||||
|
@update:model-value="commit(item.key, $event, false)"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
</UCollapsible>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { PRESET_NAMES, type PresetName } from '@/lib/roleForm'
|
||||||
|
|
||||||
|
const props = defineProps<{ active: PresetName | null; disabled?: boolean }>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ select: [preset: PresetName] }>()
|
||||||
|
|
||||||
|
function onSelect(preset: PresetName) {
|
||||||
|
if (!props.disabled) {
|
||||||
|
emit('select', preset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelOf(preset: PresetName) {
|
||||||
|
switch (preset) {
|
||||||
|
case 'read_only':
|
||||||
|
return t('Read only')
|
||||||
|
case 'write_own':
|
||||||
|
return t('Write own')
|
||||||
|
case 'full_content':
|
||||||
|
return t('Full content')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-sm font-medium text-highlighted">{{ t('Quick presets') }}</span>
|
||||||
|
<UButton
|
||||||
|
v-for="preset in PRESET_NAMES"
|
||||||
|
:key="preset"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
:aria-disabled="disabled ? 'true' : undefined"
|
||||||
|
:aria-pressed="active === preset ? 'true' : 'false'"
|
||||||
|
class="h-7 rounded-none border border-accented px-2.5 text-xs text-highlighted ring-0 max-md:h-11"
|
||||||
|
:class="active === preset ? 'bg-elevated font-semibold' : 'bg-default'"
|
||||||
|
@click="onSelect(preset)"
|
||||||
|
>
|
||||||
|
{{ labelOf(preset) }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import ReadOnlyBanner from '@/components/ReadOnlyBanner.vue'
|
||||||
|
import type { ConflictState, ReadOnlyReason } from '@/composables/useRoleEditor'
|
||||||
|
import type { RefusalCopy } from '@/lib/permissionCopy'
|
||||||
|
import type { RoleItem } from '@/lib/roles'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
role: RoleItem | null
|
||||||
|
reason: ReadOnlyReason | null
|
||||||
|
conflict: ConflictState | null
|
||||||
|
refusal: RefusalCopy | null
|
||||||
|
canDuplicate: boolean
|
||||||
|
warnMembers: boolean
|
||||||
|
reviewing: boolean
|
||||||
|
differences: { key: string; name: string; kind: 'added' | 'removed' | 'changed' }[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
duplicate: []
|
||||||
|
reloadRole: []
|
||||||
|
reloadPage: []
|
||||||
|
review: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const ALERT_UI = {
|
||||||
|
root: 'items-start gap-2.5 px-3.5 py-3',
|
||||||
|
title: 'font-semibold',
|
||||||
|
description: 'text-toned',
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindLabel(kind: 'added' | 'removed' | 'changed') {
|
||||||
|
return kind === 'added' ? t('Added') : kind === 'removed' ? t('Removed') : t('Changed')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<UAlert
|
||||||
|
v-if="reason === 'owner'"
|
||||||
|
color="info"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-lock"
|
||||||
|
:close="false"
|
||||||
|
:title="t('The Owner role is locked')"
|
||||||
|
:description="
|
||||||
|
t(
|
||||||
|
'It always has every permission. To hand it to someone else, transfer ownership from Users.',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:ui="ALERT_UI"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
class="h-8"
|
||||||
|
@click="router.push('/users')"
|
||||||
|
>
|
||||||
|
{{ t('Go to Users') }}
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</UAlert>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-else-if="reason === 'builtin'"
|
||||||
|
color="info"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-lock"
|
||||||
|
:close="false"
|
||||||
|
:title="t('Only the owner can edit built-in roles')"
|
||||||
|
:description="
|
||||||
|
t('You can read what {0} allows. To change it, duplicate it into a custom role.', [
|
||||||
|
role?.name ?? '',
|
||||||
|
])
|
||||||
|
"
|
||||||
|
:ui="ALERT_UI"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<UButton
|
||||||
|
v-if="canDuplicate"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
class="h-8"
|
||||||
|
@click="emit('duplicate')"
|
||||||
|
>
|
||||||
|
{{ t('Duplicate role') }}
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</UAlert>
|
||||||
|
|
||||||
|
<ReadOnlyBanner
|
||||||
|
v-else-if="reason === 'view'"
|
||||||
|
:title="t('You can see roles but not change them')"
|
||||||
|
:description="t('Ask an owner or admin if a role needs to change.')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ReadOnlyBanner
|
||||||
|
v-else-if="reason === 'own'"
|
||||||
|
:title="
|
||||||
|
t('You can\'t change the permissions of your own role. Ask another owner or admin.')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-else-if="reason === 'lowered'"
|
||||||
|
role="alert"
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-triangle-alert"
|
||||||
|
:close="false"
|
||||||
|
:title="t('Your access changed')"
|
||||||
|
:description="
|
||||||
|
t(
|
||||||
|
'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.',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:ui="ALERT_UI"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<UButton color="neutral" variant="outline" class="h-8" @click="emit('reloadPage')">
|
||||||
|
{{ t('Reload page') }}
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</UAlert>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="warnMembers && role"
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-triangle-alert"
|
||||||
|
:close="false"
|
||||||
|
:title="
|
||||||
|
role.members === 1
|
||||||
|
? t('This role has 1 member')
|
||||||
|
: t('This role has {0} members', [role.members])
|
||||||
|
"
|
||||||
|
:description="
|
||||||
|
t(
|
||||||
|
'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.',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:ui="ALERT_UI"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="conflict"
|
||||||
|
role="alert"
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-triangle-alert"
|
||||||
|
:close="false"
|
||||||
|
:title="conflict.title"
|
||||||
|
:description="conflict.description"
|
||||||
|
:ui="ALERT_UI"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<UButton color="neutral" variant="outline" class="h-8" @click="emit('reloadRole')">
|
||||||
|
{{ t('Reload role') }}
|
||||||
|
</UButton>
|
||||||
|
<UButton color="neutral" variant="outline" class="h-8" @click="emit('review')">
|
||||||
|
{{ t('Review differences') }}
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</UAlert>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
v-if="conflict && reviewing"
|
||||||
|
data-testid="role-differences"
|
||||||
|
class="flex flex-col gap-1 border border-default bg-default p-3"
|
||||||
|
>
|
||||||
|
<li v-for="entry in differences" :key="entry.key" class="flex gap-2 text-sm">
|
||||||
|
<span class="font-medium text-highlighted">{{ kindLabel(entry.kind) }}</span>
|
||||||
|
<span class="text-muted">{{ entry.name }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="refusal"
|
||||||
|
role="alert"
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-circle-x"
|
||||||
|
:close="false"
|
||||||
|
:title="refusal.title"
|
||||||
|
:description="refusal.description"
|
||||||
|
:ui="ALERT_UI"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { RoleItem } from '@/lib/roles'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
roles: RoleItem[]
|
||||||
|
sourceId: number
|
||||||
|
canAssign: (role: RoleItem) => boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const selected = defineModel<number | undefined>('selected', { default: undefined })
|
||||||
|
|
||||||
|
const items = computed(() =>
|
||||||
|
props.roles
|
||||||
|
.filter((role) => !role.locked && role.id !== props.sourceId)
|
||||||
|
.map((role) => {
|
||||||
|
const allowed = props.canAssign(role)
|
||||||
|
return {
|
||||||
|
label: role.name,
|
||||||
|
value: role.id,
|
||||||
|
description: allowed
|
||||||
|
? role.description
|
||||||
|
: t("Your role can't assign a role with more power than yours."),
|
||||||
|
disabled: !allowed,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<USelectMenu
|
||||||
|
v-model="selected"
|
||||||
|
:items="items"
|
||||||
|
value-key="value"
|
||||||
|
:search-input="false"
|
||||||
|
:placeholder="t('Choose a role')"
|
||||||
|
:portal="false"
|
||||||
|
:content="{ position: 'inline' }"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{
|
||||||
|
base: 'h-11 rounded-none md:h-10',
|
||||||
|
content: 'rounded-none border border-default',
|
||||||
|
item: 'min-h-12 gap-2 px-2 py-1 md:min-h-9',
|
||||||
|
itemDescription: 'text-xs text-muted',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import RolePreviewBody from '@/components/RolePreviewBody.vue'
|
||||||
|
import { BOTTOM_SHEET_UI } from '@/lib/sheetUi'
|
||||||
|
import type { RolePreviewModel } from '@/lib/rolePreview'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
model: RolePreviewModel
|
||||||
|
readOnly?: boolean
|
||||||
|
mobile?: boolean
|
||||||
|
}>(),
|
||||||
|
{ readOnly: false, mobile: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
const open = defineModel<boolean>('open', { default: false })
|
||||||
|
|
||||||
|
const subtitle = computed(() =>
|
||||||
|
props.readOnly ? t('A summary of what this role allows.') : t('Updates as you edit.'),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<USlideover
|
||||||
|
v-if="mobile"
|
||||||
|
v-model:open="open"
|
||||||
|
side="bottom"
|
||||||
|
:title="t('What this role can do')"
|
||||||
|
:description="subtitle"
|
||||||
|
:ui="BOTTOM_SHEET_UI"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<p class="text-muted">{{ subtitle }}</p>
|
||||||
|
<RolePreviewBody :model="model" />
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
class="h-11 flex-1 justify-center"
|
||||||
|
@click="open = false"
|
||||||
|
>
|
||||||
|
{{ t('Close') }}
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</USlideover>
|
||||||
|
<UCard
|
||||||
|
v-else
|
||||||
|
data-testid="role-preview"
|
||||||
|
:ui="{ root: 'rounded-none', header: 'flex flex-col gap-0.5', body: 'p-4 sm:p-4' }"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-base font-semibold text-highlighted">
|
||||||
|
{{ t('What this role can do') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted">{{ subtitle }}</p>
|
||||||
|
</template>
|
||||||
|
<RolePreviewBody :model="model" />
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import type { PreviewLine, RolePreviewModel } from '@/lib/rolePreview'
|
||||||
|
|
||||||
|
defineProps<{ model: RolePreviewModel }>()
|
||||||
|
|
||||||
|
function key(prefix: string, line: PreviewLine) {
|
||||||
|
return `${prefix}-${line.id}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<section class="flex flex-col gap-2">
|
||||||
|
<h3 class="text-sm font-semibold text-highlighted">{{ t('Content') }}</h3>
|
||||||
|
<div
|
||||||
|
v-for="line in model.content"
|
||||||
|
:key="key('content', line)"
|
||||||
|
data-testid="preview-line"
|
||||||
|
class="flex flex-col gap-0.5"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-2 font-medium text-highlighted">
|
||||||
|
{{ line.label }}
|
||||||
|
<UBadge
|
||||||
|
v-if="line.changed"
|
||||||
|
color="primary"
|
||||||
|
variant="subtle"
|
||||||
|
:label="t('Changed')"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="text-toned">{{ line.sentence }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section v-if="model.everything.length > 0" class="flex flex-col gap-2">
|
||||||
|
<h3 class="text-sm font-semibold text-highlighted">{{ t('Everything else') }}</h3>
|
||||||
|
<div
|
||||||
|
v-for="line in model.everything"
|
||||||
|
:key="key('everything', line)"
|
||||||
|
data-testid="preview-line"
|
||||||
|
class="flex flex-col gap-0.5"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-2 font-medium text-highlighted">
|
||||||
|
{{ line.label }}
|
||||||
|
<UBadge
|
||||||
|
v-if="line.changed"
|
||||||
|
color="primary"
|
||||||
|
variant="subtle"
|
||||||
|
:label="t('Changed')"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="text-toned">{{ line.sentence }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<p v-if="model.noAccess" data-testid="preview-no-access" class="text-muted">
|
||||||
|
{{ model.noAccess }}
|
||||||
|
</p>
|
||||||
|
<p v-if="model.members" data-testid="preview-members" class="text-muted">
|
||||||
|
{{ model.members }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
count: number
|
||||||
|
problems: number
|
||||||
|
saving: boolean
|
||||||
|
conflicted: boolean
|
||||||
|
canSave: boolean
|
||||||
|
nameMissing: boolean
|
||||||
|
isNew: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{ discard: []; save: [] }>()
|
||||||
|
|
||||||
|
const message = computed(() => {
|
||||||
|
if (props.saving) {
|
||||||
|
return t('Saving…')
|
||||||
|
}
|
||||||
|
if (props.conflicted) {
|
||||||
|
return props.count === 1
|
||||||
|
? t('1 unsaved change. Save is off until you reload')
|
||||||
|
: t('{0} unsaved changes. Save is off until you reload', [props.count])
|
||||||
|
}
|
||||||
|
if (props.problems > 0) {
|
||||||
|
return props.problems === 1
|
||||||
|
? t('1 problem to fix')
|
||||||
|
: t('{0} problems to fix', [props.problems])
|
||||||
|
}
|
||||||
|
const count =
|
||||||
|
props.count === 0
|
||||||
|
? ''
|
||||||
|
: props.count === 1
|
||||||
|
? t('1 unsaved change')
|
||||||
|
: t('{0} unsaved changes', [props.count])
|
||||||
|
if (props.isNew && props.nameMissing) {
|
||||||
|
return count === ''
|
||||||
|
? t('Name your role to save it')
|
||||||
|
: `${count} · ${t('Name your role to save it')}`
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-testid="role-save-bar"
|
||||||
|
class="flex items-center justify-between gap-3 border-t border-default bg-default px-4 py-3"
|
||||||
|
>
|
||||||
|
<span role="status" class="text-sm text-highlighted">{{ message }}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
class="h-11 px-3 md:h-8"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="$emit('discard')"
|
||||||
|
>
|
||||||
|
{{ t('Discard') }}
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
class="h-11 px-3 md:h-8"
|
||||||
|
:loading="saving"
|
||||||
|
:disabled="!canSave"
|
||||||
|
@click="$emit('save')"
|
||||||
|
>
|
||||||
|
{{ t('Save changes') }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import DirectoryRowMenu from '@/components/DirectoryRowMenu.vue'
|
||||||
|
import { roleMenuItems, type RoleMenuActions } from '@/lib/roleMenu'
|
||||||
|
import type { RoleItem } from '@/lib/roles'
|
||||||
|
|
||||||
|
const ROW_GRID =
|
||||||
|
'grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)_80px_170px_44px] items-center gap-3 px-3'
|
||||||
|
const SKELETON_ROWS = 4
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
roles: RoleItem[]
|
||||||
|
loading: boolean
|
||||||
|
desktop: boolean
|
||||||
|
actions: RoleMenuActions
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="desktop"
|
||||||
|
class="border border-default bg-default"
|
||||||
|
:aria-busy="loading ? 'true' : undefined"
|
||||||
|
>
|
||||||
|
<div :class="ROW_GRID" class="h-9 bg-elevated font-medium text-muted">
|
||||||
|
<span>{{ t('Role') }}</span>
|
||||||
|
<span>{{ t('Description') }}</span>
|
||||||
|
<span>{{ t('Members') }}</span>
|
||||||
|
<span>{{ t('Type') }}</span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
<template v-if="loading">
|
||||||
|
<div
|
||||||
|
v-for="n in SKELETON_ROWS"
|
||||||
|
:key="n"
|
||||||
|
:class="ROW_GRID"
|
||||||
|
data-testid="role-skeleton"
|
||||||
|
class="h-12 border-t border-default"
|
||||||
|
>
|
||||||
|
<USkeleton class="h-3 w-[60%] rounded-none bg-accented" />
|
||||||
|
<USkeleton class="h-3 w-[80%] rounded-none bg-accented" />
|
||||||
|
<USkeleton class="h-3 w-6 rounded-none bg-accented" />
|
||||||
|
<USkeleton class="h-5 w-16 rounded-none bg-accented" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-for="role in roles"
|
||||||
|
:key="role.id"
|
||||||
|
data-testid="role-row"
|
||||||
|
:class="ROW_GRID"
|
||||||
|
class="min-h-12 border-t border-default py-2"
|
||||||
|
>
|
||||||
|
<RouterLink
|
||||||
|
:to="`/settings/roles/${role.id}`"
|
||||||
|
class="truncate font-medium text-highlighted hover:underline"
|
||||||
|
>{{ role.name }}</RouterLink
|
||||||
|
>
|
||||||
|
<span class="truncate text-muted">{{ role.description }}</span>
|
||||||
|
<span class="text-muted">{{ role.members }}</span>
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
<UBadge
|
||||||
|
v-if="role.locked"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-lock"
|
||||||
|
:label="t('Locked')"
|
||||||
|
/>
|
||||||
|
<UBadge
|
||||||
|
color="neutral"
|
||||||
|
variant="subtle"
|
||||||
|
:label="role.built_in ? t('Built-in') : t('Custom')"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<DirectoryRowMenu
|
||||||
|
:items="roleMenuItems(role, actions)"
|
||||||
|
:title="role.name"
|
||||||
|
:desktop="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-col gap-2" :aria-busy="loading ? 'true' : undefined">
|
||||||
|
<template v-if="loading">
|
||||||
|
<USkeleton
|
||||||
|
v-for="n in SKELETON_ROWS"
|
||||||
|
:key="n"
|
||||||
|
data-testid="role-skeleton"
|
||||||
|
class="h-20 rounded-none bg-accented"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-for="role in roles"
|
||||||
|
:key="role.id"
|
||||||
|
data-testid="role-card"
|
||||||
|
class="flex items-start justify-between gap-2 border border-default bg-default p-3"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-col gap-1">
|
||||||
|
<RouterLink
|
||||||
|
:to="`/settings/roles/${role.id}`"
|
||||||
|
class="font-medium text-highlighted"
|
||||||
|
>{{ role.name }}</RouterLink
|
||||||
|
>
|
||||||
|
<span class="text-muted">{{ role.description }}</span>
|
||||||
|
<span class="flex items-center gap-1.5 text-xs text-muted">
|
||||||
|
<UBadge
|
||||||
|
v-if="role.locked"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-lock"
|
||||||
|
:label="t('Locked')"
|
||||||
|
/>
|
||||||
|
<UBadge
|
||||||
|
color="neutral"
|
||||||
|
variant="subtle"
|
||||||
|
:label="role.built_in ? t('Built-in') : t('Custom')"
|
||||||
|
/>
|
||||||
|
{{ t('{0} members', [role.members]) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<DirectoryRowMenu
|
||||||
|
:items="roleMenuItems(role, actions)"
|
||||||
|
:title="role.name"
|
||||||
|
:desktop="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { computed, nextTick, ref, useId } from 'vue'
|
||||||
|
import type { Scope } from '@/lib/permissions'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
modelValue: Scope
|
||||||
|
disabled?: boolean
|
||||||
|
locks?: Partial<Record<Scope, string>>
|
||||||
|
changed?: boolean
|
||||||
|
}>(),
|
||||||
|
{ disabled: false, locks: () => ({}), changed: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [scope: Scope] }>()
|
||||||
|
|
||||||
|
const SCOPES: Scope[] = ['none', 'own', 'all']
|
||||||
|
|
||||||
|
const group = ref<HTMLElement | null>(null)
|
||||||
|
const uid = useId()
|
||||||
|
|
||||||
|
const labels = computed<Record<Scope, string>>(() => ({
|
||||||
|
none: t('None'),
|
||||||
|
own: t('Own'),
|
||||||
|
all: t('All'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
function isLocked(scope: Scope) {
|
||||||
|
return props.disabled || props.locks[scope] !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabledScopes = computed(() => SCOPES.filter((scope) => !isLocked(scope)))
|
||||||
|
|
||||||
|
const tabStop = computed(() => {
|
||||||
|
const candidates = props.disabled ? SCOPES : enabledScopes.value
|
||||||
|
return candidates.includes(props.modelValue) ? props.modelValue : candidates[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
function select(scope: Scope) {
|
||||||
|
if (isLocked(scope) || scope === props.modelValue) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('update:modelValue', scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveTo(scope: Scope | undefined) {
|
||||||
|
if (scope === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select(scope)
|
||||||
|
await nextTick()
|
||||||
|
group.value?.querySelector<HTMLElement>(`[data-scope="${scope}"]`)?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusedScope(): Scope {
|
||||||
|
const active = document.activeElement?.getAttribute('data-scope')
|
||||||
|
return (SCOPES.find((scope) => scope === active) ?? props.modelValue) as Scope
|
||||||
|
}
|
||||||
|
|
||||||
|
function neighbour(step: 1 | -1): Scope | undefined {
|
||||||
|
const current = SCOPES.indexOf(focusedScope())
|
||||||
|
const ordered = step === 1 ? SCOPES.slice(current + 1) : SCOPES.slice(0, current).reverse()
|
||||||
|
return ordered.find((scope) => !isLocked(scope))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
const handlers: Record<string, () => Scope | undefined> = {
|
||||||
|
ArrowRight: () => neighbour(1),
|
||||||
|
ArrowDown: () => neighbour(1),
|
||||||
|
ArrowLeft: () => neighbour(-1),
|
||||||
|
ArrowUp: () => neighbour(-1),
|
||||||
|
Home: () => enabledScopes.value[0],
|
||||||
|
End: () => enabledScopes.value[enabledScopes.value.length - 1],
|
||||||
|
' ': () => focusedScope(),
|
||||||
|
}
|
||||||
|
const handler = handlers[event.key]
|
||||||
|
if (handler === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
void moveTo(handler())
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="relative inline-flex">
|
||||||
|
<span
|
||||||
|
v-if="changed"
|
||||||
|
data-testid="changed-marker"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="absolute -top-1 -left-1 z-10 size-1.5 bg-primary"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
ref="group"
|
||||||
|
role="radiogroup"
|
||||||
|
:aria-label="label"
|
||||||
|
:aria-disabled="disabled ? 'true' : undefined"
|
||||||
|
class="inline-flex border border-default"
|
||||||
|
:class="{ 'opacity-60': disabled }"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
>
|
||||||
|
<template v-for="scope in SCOPES" :key="scope">
|
||||||
|
<UTooltip :text="locks[scope] ?? ''" :disabled="locks[scope] === undefined">
|
||||||
|
<UButton
|
||||||
|
role="radio"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
:data-scope="scope"
|
||||||
|
:aria-checked="modelValue === scope ? 'true' : 'false'"
|
||||||
|
:aria-disabled="isLocked(scope) ? 'true' : undefined"
|
||||||
|
:aria-describedby="locks[scope] ? `${uid}-${scope}-reason` : undefined"
|
||||||
|
:tabindex="tabStop === scope ? 0 : -1"
|
||||||
|
:icon="locks[scope] ? 'i-lucide-lock' : undefined"
|
||||||
|
class="h-6.5 min-w-11 justify-center rounded-none px-2.5 text-xs max-md:h-11 max-md:min-w-14"
|
||||||
|
:class="
|
||||||
|
modelValue === scope
|
||||||
|
? 'border-b-2 border-primary bg-elevated font-semibold text-highlighted'
|
||||||
|
: 'text-muted'
|
||||||
|
"
|
||||||
|
:ui="{ leadingIcon: 'size-3' }"
|
||||||
|
@click="select(scope)"
|
||||||
|
>
|
||||||
|
{{ labels[scope] }}
|
||||||
|
</UButton>
|
||||||
|
</UTooltip>
|
||||||
|
<span v-if="locks[scope]" :id="`${uid}-${scope}-reason`" class="sr-only">{{
|
||||||
|
locks[scope]
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
@@ -769,7 +769,7 @@ describe('DashboardShell mobile drawer rows', () => {
|
|||||||
const drawer = await openDrawer('/settings')
|
const drawer = await openDrawer('/settings')
|
||||||
|
|
||||||
const links = Array.from(drawer.querySelectorAll('nav[aria-label="Main"] a'))
|
const links = Array.from(drawer.querySelectorAll('nav[aria-label="Main"] a'))
|
||||||
expect(links).toHaveLength(12)
|
expect(links).toHaveLength(13)
|
||||||
for (const link of links) {
|
for (const link of links) {
|
||||||
expect(classesOf(link)).toContain('max-lg:h-11')
|
expect(classesOf(link)).toContain('max-lg:h-11')
|
||||||
}
|
}
|
||||||
@@ -876,3 +876,51 @@ describe('DashboardShell lists items per permission', () => {
|
|||||||
expect(wrapper.find('a[href="/settings/sites"]').exists()).toBe(true)
|
expect(wrapper.find('a[href="/settings/sites"]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('DashboardShell Access group', () => {
|
||||||
|
it('shows Access with Roles (shield icon) under Settings in the sidebar with roles.view', async () => {
|
||||||
|
const wrapper = await mountShell('/settings', {
|
||||||
|
access: customAccess('settings.view, roles.view'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const roles = wrapper.find('a[href="/settings/roles"]')
|
||||||
|
expect(roles.exists()).toBe(true)
|
||||||
|
expect(roles.text()).toContain('Roles')
|
||||||
|
expect(roles.find('svg').exists()).toBe(true)
|
||||||
|
expect(wrapper.text()).toContain('Access')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Roles in the mobile drawer with roles.view', async () => {
|
||||||
|
await mountShell('/settings', {
|
||||||
|
access: customAccess('settings.view, roles.view'),
|
||||||
|
width: MOBILE_WIDTH,
|
||||||
|
})
|
||||||
|
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
|
||||||
|
toggle.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const drawer = document.body.querySelector('[role="dialog"]') as HTMLElement
|
||||||
|
expect(drawer.querySelector('a[href="/settings/roles"]')).not.toBeNull()
|
||||||
|
expect(drawer.textContent).toContain('Access')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has no Roles entry without roles.view', async () => {
|
||||||
|
const wrapper = await mountShell('/settings', { access: customAccess('settings.view') })
|
||||||
|
|
||||||
|
expect(wrapper.find('a[href="/settings/roles"]').exists()).toBe(false)
|
||||||
|
expect(wrapper.text()).not.toContain('Access')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has no Permissions entry anywhere', async () => {
|
||||||
|
const wrapper = await mountShell('/settings', { role: 'owner' })
|
||||||
|
|
||||||
|
expect(wrapper.find('a[href="/settings/permissions"]').exists()).toBe(false)
|
||||||
|
expect(wrapper.text()).not.toContain('Permissions')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists Roles for a person who can only view roles', async () => {
|
||||||
|
const wrapper = await mountShell('/settings', { access: customAccess('roles.view') })
|
||||||
|
|
||||||
|
expect(wrapper.find('a[href="/settings/roles"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { ApiError } from '@/composables/useApi'
|
||||||
|
import DeleteRoleDialog from '../DeleteRoleDialog.vue'
|
||||||
|
import { renderInApp } from './roleKit'
|
||||||
|
import { canCover } from '@/lib/grantLocks'
|
||||||
|
import { effectiveScope } from '@/lib/permissions'
|
||||||
|
import { grantsFrom } from '@/test/access'
|
||||||
|
import type { RoleItem } from '@/lib/roles'
|
||||||
|
|
||||||
|
const reassignRole = vi.fn()
|
||||||
|
const deleteRole = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('@/composables/useRoles', () => ({
|
||||||
|
useRoles: () => ({ reassignRole, deleteRole }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
let wrapper: VueWrapper | null = null
|
||||||
|
|
||||||
|
function role(overrides: Partial<RoleItem> & Pick<RoleItem, 'id' | 'name'>): RoleItem {
|
||||||
|
return {
|
||||||
|
description: '',
|
||||||
|
built_in: false,
|
||||||
|
locked: false,
|
||||||
|
version: 1,
|
||||||
|
grants: [],
|
||||||
|
members: 0,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const OWNER = role({ id: 1, name: 'Owner', built_in: true, locked: true, members: 1 })
|
||||||
|
const ADMIN = role({
|
||||||
|
id: 2,
|
||||||
|
name: 'Admin',
|
||||||
|
built_in: true,
|
||||||
|
description: 'Everything except owner-only.',
|
||||||
|
grants: grantsFrom('content.publish, users.view'),
|
||||||
|
})
|
||||||
|
const WRITER = role({
|
||||||
|
id: 3,
|
||||||
|
name: 'Writer',
|
||||||
|
built_in: true,
|
||||||
|
description: "Reads everything and writes content. Can't delete or publish.",
|
||||||
|
grants: grantsFrom('content.read, content.update'),
|
||||||
|
members: 5,
|
||||||
|
})
|
||||||
|
const CONTRIBUTOR = role({ id: 4, name: 'Contributor', members: 4 })
|
||||||
|
const LEAD = role({
|
||||||
|
id: 5,
|
||||||
|
name: 'Content lead',
|
||||||
|
description: 'Edits and publishes all content.',
|
||||||
|
grants: grantsFrom('content.read, content.update, content.publish'),
|
||||||
|
})
|
||||||
|
const INTERN = role({ id: 6, name: 'Intern', members: 0 })
|
||||||
|
const ROLES = [OWNER, ADMIN, WRITER, CONTRIBUTOR, LEAD, INTERN]
|
||||||
|
|
||||||
|
function ownerCan() {
|
||||||
|
return () => true
|
||||||
|
}
|
||||||
|
|
||||||
|
function limitedCan() {
|
||||||
|
const held = grantsFrom('content.read, content.update')
|
||||||
|
return (target: RoleItem) =>
|
||||||
|
canCover(target, (key, options) => effectiveScope(held, key, options))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountDialog(target: RoleItem, canAssign = ownerCan()) {
|
||||||
|
const events: Record<string, unknown[]> = { deleted: [], gone: [], duplicate: [], open: [] }
|
||||||
|
wrapper = renderInApp(DeleteRoleDialog, {
|
||||||
|
role: target,
|
||||||
|
roles: ROLES,
|
||||||
|
canAssign,
|
||||||
|
open: true,
|
||||||
|
onDeleted: (value: RoleItem) => events.deleted!.push(value),
|
||||||
|
onGone: (value: RoleItem) => events.gone!.push(value),
|
||||||
|
onDuplicate: (value: RoleItem) => events.duplicate!.push(value),
|
||||||
|
'onUpdate:open': (value: boolean) => events.open!.push(value),
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
function dialog() {
|
||||||
|
return document.body.querySelector('[role="alertdialog"]') as HTMLElement
|
||||||
|
}
|
||||||
|
|
||||||
|
function button(label: string) {
|
||||||
|
return Array.from(dialog().querySelectorAll('button')).find(
|
||||||
|
(candidate) => candidate.textContent?.trim() === label,
|
||||||
|
) as HTMLButtonElement | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPicker() {
|
||||||
|
const trigger = dialog().querySelector('[aria-haspopup="listbox"]') as HTMLElement
|
||||||
|
trigger.click()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function choose(name: string) {
|
||||||
|
await openPicker()
|
||||||
|
const option = Array.from(document.body.querySelectorAll('[role="option"]')).find((candidate) =>
|
||||||
|
candidate.textContent?.includes(name),
|
||||||
|
) as HTMLElement
|
||||||
|
option.click()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionNamed(name: string) {
|
||||||
|
return Array.from(document.body.querySelectorAll('[role="option"]')).find((candidate) =>
|
||||||
|
candidate.textContent?.includes(name),
|
||||||
|
) as HTMLElement | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Element.prototype.scrollIntoView = vi.fn()
|
||||||
|
reassignRole.mockReset()
|
||||||
|
deleteRole.mockReset()
|
||||||
|
reassignRole.mockResolvedValue({ moved: 4 })
|
||||||
|
deleteRole.mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
wrapper = null
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Blocked: has members', () => {
|
||||||
|
it('reads the blocked copy and keeps the action disabled until a role is chosen', async () => {
|
||||||
|
await mountDialog(CONTRIBUTOR)
|
||||||
|
|
||||||
|
const text = dialog().textContent!
|
||||||
|
expect(text).toContain('Delete Contributor?')
|
||||||
|
expect(text).toContain(
|
||||||
|
'4 members have this role. Move them to another role first. Then Contributor can be deleted.',
|
||||||
|
)
|
||||||
|
expect(text).toContain('Move members to')
|
||||||
|
expect(text).toContain('They keep their API keys. Keys follow the new role.')
|
||||||
|
expect(dialog().querySelector('[aria-haspopup="listbox"]')?.textContent).toContain(
|
||||||
|
'Choose a role',
|
||||||
|
)
|
||||||
|
expect(button('Move 4 members and delete role')!.disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves the members then deletes the role, and closes on both successes', async () => {
|
||||||
|
const events = await mountDialog(CONTRIBUTOR)
|
||||||
|
await choose('Writer')
|
||||||
|
|
||||||
|
button('Move 4 members and delete role')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(reassignRole).toHaveBeenCalledWith(4, 3)
|
||||||
|
expect(deleteRole).toHaveBeenCalledWith(4)
|
||||||
|
expect(reassignRole.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
deleteRole.mock.invocationCallOrder[0]!,
|
||||||
|
)
|
||||||
|
expect(events.deleted).toEqual([CONTRIBUTOR])
|
||||||
|
expect(events.open).toContain(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the dialog open with the refusal copy when the reassign fails', async () => {
|
||||||
|
reassignRole.mockRejectedValue(
|
||||||
|
new ApiError(400, undefined, 'invalid_reassign', { code: 'invalid_reassign' }),
|
||||||
|
)
|
||||||
|
const events = await mountDialog(CONTRIBUTOR)
|
||||||
|
await choose('Writer')
|
||||||
|
|
||||||
|
button('Move 4 members and delete role')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(deleteRole).not.toHaveBeenCalled()
|
||||||
|
expect(events.deleted).toEqual([])
|
||||||
|
expect(dialog()).not.toBeNull()
|
||||||
|
expect(dialog().querySelector('[role="alert"]')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the dialog open with the shared refusal copy when the delete fails', async () => {
|
||||||
|
deleteRole.mockRejectedValue(
|
||||||
|
new ApiError(403, undefined, 'escalation', { code: 'escalation' }),
|
||||||
|
)
|
||||||
|
const events = await mountDialog(CONTRIBUTOR)
|
||||||
|
await choose('Writer')
|
||||||
|
|
||||||
|
button('Move 4 members and delete role')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(events.deleted).toEqual([])
|
||||||
|
expect(dialog().textContent).toContain("You can't grant that")
|
||||||
|
expect(dialog().textContent).toContain('You can only grant what your role has.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('goes back to the blocked state with the live count on role_has_members', async () => {
|
||||||
|
deleteRole.mockRejectedValue(
|
||||||
|
new ApiError(409, undefined, 'role_has_members', { code: 'role_has_members' }),
|
||||||
|
)
|
||||||
|
const events = await mountDialog(INTERN)
|
||||||
|
|
||||||
|
button('Delete role')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(events.deleted).toEqual([])
|
||||||
|
expect(dialog().textContent).toContain('Move members to')
|
||||||
|
expect(dialog().textContent).toContain('member')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asks the list to refresh when the role is already gone', async () => {
|
||||||
|
deleteRole.mockRejectedValue(
|
||||||
|
new ApiError(404, undefined, 'role_not_found', { code: 'role_not_found' }),
|
||||||
|
)
|
||||||
|
const events = await mountDialog(INTERN)
|
||||||
|
|
||||||
|
button('Delete role')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(events.gone).toEqual([INTERN])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Role picker', () => {
|
||||||
|
it('does not offer Owner or the source role', async () => {
|
||||||
|
await mountDialog(CONTRIBUTOR)
|
||||||
|
await openPicker()
|
||||||
|
|
||||||
|
expect(optionNamed('Owner')).toBeUndefined()
|
||||||
|
expect(optionNamed('Contributor')).toBeUndefined()
|
||||||
|
expect(optionNamed('Writer')!.textContent).toContain('Reads everything and writes content.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enables Content lead and Admin for a caller who covers them', async () => {
|
||||||
|
await mountDialog(CONTRIBUTOR)
|
||||||
|
await openPicker()
|
||||||
|
|
||||||
|
expect(optionNamed('Content lead')!.getAttribute('data-disabled')).toBeNull()
|
||||||
|
expect(optionNamed('Admin')!.getAttribute('data-disabled')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables the roles a caller cannot cover, with the reason', async () => {
|
||||||
|
await mountDialog(CONTRIBUTOR, limitedCan())
|
||||||
|
await openPicker()
|
||||||
|
|
||||||
|
for (const name of ['Content lead', 'Admin']) {
|
||||||
|
const option = optionNamed(name)!
|
||||||
|
expect(
|
||||||
|
option.getAttribute('aria-disabled') ?? option.getAttribute('data-disabled'),
|
||||||
|
).not.toBeNull()
|
||||||
|
expect(option.textContent).toContain(
|
||||||
|
"Your role can't assign a role with more power than yours.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
expect(optionNamed('Writer')!.getAttribute('data-disabled')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Confirm: no members', () => {
|
||||||
|
it('reads the confirm copy and deletes without a reassign call', async () => {
|
||||||
|
const events = await mountDialog(INTERN)
|
||||||
|
|
||||||
|
const text = dialog().textContent!
|
||||||
|
expect(text).toContain('Delete Intern?')
|
||||||
|
expect(text).toContain("No one has this role. This can't be undone.")
|
||||||
|
expect(text).toContain("Deleting a role doesn't affect content.")
|
||||||
|
expect(button('Cancel')).toBeDefined()
|
||||||
|
|
||||||
|
button('Delete role')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(reassignRole).not.toHaveBeenCalled()
|
||||||
|
expect(deleteRole).toHaveBeenCalledWith(6)
|
||||||
|
expect(events.deleted).toEqual([INTERN])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Built-in role dialog', () => {
|
||||||
|
it('reads that Writer is built in and offers Duplicate role and Close', async () => {
|
||||||
|
const events = await mountDialog(WRITER)
|
||||||
|
|
||||||
|
const text = dialog().textContent!
|
||||||
|
expect(text).toContain('Delete Writer?')
|
||||||
|
expect(text).toContain("Built-in roles can't be deleted.")
|
||||||
|
expect(text).toContain('Writer is built in')
|
||||||
|
expect(text).toContain('Duplicate it to make a custom role you can change or delete.')
|
||||||
|
expect(button('Close')).toBeDefined()
|
||||||
|
|
||||||
|
button('Duplicate role')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(events.duplicate).toEqual([WRITER])
|
||||||
|
expect(deleteRole).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import PermissionGrid, { type GridChange } from '../PermissionGrid.vue'
|
||||||
|
import PresetPills from '../PresetPills.vue'
|
||||||
|
import { radiosOf, renderInApp } from './roleKit'
|
||||||
|
import { effectiveScope } from '@/lib/permissions'
|
||||||
|
import { applyPreset } from '@/lib/roleForm'
|
||||||
|
import { grantsFrom } from '@/test/access'
|
||||||
|
|
||||||
|
let wrapper: VueWrapper | null = null
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
wrapper = null
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const TYPES = [
|
||||||
|
{ key: 'article', name: 'Articles', builtin: true },
|
||||||
|
{ key: 'events', name: 'Events' },
|
||||||
|
{ key: 'case-studies', name: 'Case studies' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function callerFor(spec: string) {
|
||||||
|
const grants = grantsFrom(spec)
|
||||||
|
return (key: string, options?: { typeKey?: string }) => effectiveScope(grants, key, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountGrid(props: Record<string, unknown> = {}) {
|
||||||
|
const changes: GridChange[] = []
|
||||||
|
const resets: string[] = []
|
||||||
|
const grants = (props.grants as Record<string, string>) ?? {}
|
||||||
|
wrapper = renderInApp(PermissionGrid, {
|
||||||
|
types: TYPES,
|
||||||
|
grants,
|
||||||
|
initial: grants,
|
||||||
|
callerScope: callerFor(
|
||||||
|
'content.read, content.create, content.update, content.delete, content.publish',
|
||||||
|
),
|
||||||
|
onChange: (change: GridChange) => changes.push(change),
|
||||||
|
onReset: (typeKey: string) => resets.push(typeKey),
|
||||||
|
...props,
|
||||||
|
})
|
||||||
|
return { changes, resets }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PermissionGrid', () => {
|
||||||
|
it('draws a grid with the five action columns, the All content types row and one row per type', () => {
|
||||||
|
mountGrid()
|
||||||
|
|
||||||
|
expect(wrapper!.find('[role="grid"]').exists()).toBe(true)
|
||||||
|
const headers = wrapper!.findAll('[role="columnheader"]').map((header) => header.text())
|
||||||
|
expect(headers).toEqual(['Content type', 'Read', 'Create', 'Update', 'Delete', 'Publish'])
|
||||||
|
expect(wrapper!.find('[data-testid="row-all"]').text()).toContain('Sets every row')
|
||||||
|
for (const type of TYPES) {
|
||||||
|
expect(wrapper!.find(`[data-testid="row-${type.key}"]`).text()).toContain(type.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tags Articles as Built-in and Schema locked', () => {
|
||||||
|
mountGrid()
|
||||||
|
|
||||||
|
const row = wrapper!.find('[data-testid="row-article"]').text()
|
||||||
|
expect(row).toContain('Built-in')
|
||||||
|
expect(row).toContain('Schema locked')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Read, Update, Delete and Publish as radiogroups and Create as a switch', () => {
|
||||||
|
mountGrid()
|
||||||
|
|
||||||
|
for (const action of ['Read', 'Update', 'Delete', 'Publish']) {
|
||||||
|
expect(radiosOf(wrapper!, `Events: ${action}`)).toHaveLength(3)
|
||||||
|
}
|
||||||
|
expect(radiosOf(wrapper!, 'Events: Create')).toHaveLength(0)
|
||||||
|
expect(wrapper!.find('[role="switch"][aria-label="Events: Create"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the stored values, with typed rows following the wildcard row', () => {
|
||||||
|
mountGrid({ grants: applyPreset({}, 'write_own') })
|
||||||
|
|
||||||
|
const checked = (name: string) =>
|
||||||
|
radiosOf(wrapper!, name)
|
||||||
|
.find((radio) => radio.getAttribute('aria-checked') === 'true')
|
||||||
|
?.textContent?.trim()
|
||||||
|
expect(checked('All content types: Update')).toBe('Own')
|
||||||
|
expect(checked('Events: Update')).toBe('Own')
|
||||||
|
expect(checked('Events: Delete')).toBe('None')
|
||||||
|
expect(checked('Events: Read')).toBe('All')
|
||||||
|
expect(
|
||||||
|
wrapper!
|
||||||
|
.find('[role="switch"][aria-label="Events: Create"]')
|
||||||
|
.attributes('aria-checked'),
|
||||||
|
).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits the change for a type row and announces it politely', async () => {
|
||||||
|
const { changes } = mountGrid({ grants: applyPreset({}, 'write_own') })
|
||||||
|
|
||||||
|
radiosOf(wrapper!, 'Events: Update')[2]!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(changes).toEqual([{ action: 'update', scope: 'all', typeKey: 'events' }])
|
||||||
|
const live = wrapper!.find('[data-testid="grid-live-region"]')
|
||||||
|
expect(live.attributes('aria-live')).toBe('polite')
|
||||||
|
expect(live.text()).toBe('Events, Update, set to All.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits the wildcard change without a type key from the All content types row', () => {
|
||||||
|
const { changes } = mountGrid()
|
||||||
|
|
||||||
|
radiosOf(wrapper!, 'All content types: Read')[1]!.click()
|
||||||
|
|
||||||
|
expect(changes).toEqual([{ action: 'read', scope: 'own' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('turns Create on with scope all', async () => {
|
||||||
|
const { changes } = mountGrid()
|
||||||
|
|
||||||
|
await wrapper!.find('[role="switch"][aria-label="Events: Create"]').trigger('click')
|
||||||
|
|
||||||
|
expect(changes).toEqual([{ action: 'create', scope: 'all', typeKey: 'events' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Custom and Reset on a row with a typed grant and emits reset', async () => {
|
||||||
|
const { resets } = mountGrid({
|
||||||
|
grants: { 'content.read': 'all', 'content.update:case-studies': 'all' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const row = wrapper!.find('[data-testid="row-case-studies"]')
|
||||||
|
expect(row.text()).toContain('Custom')
|
||||||
|
expect(wrapper!.find('[data-testid="row-events"]').text()).not.toContain('Custom')
|
||||||
|
await row
|
||||||
|
.findAll('button')
|
||||||
|
.find((button) => button.text() === 'Reset')!
|
||||||
|
.trigger('click')
|
||||||
|
|
||||||
|
expect(resets).toEqual(['case-studies'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a changed cell with a square marker', () => {
|
||||||
|
mountGrid({
|
||||||
|
grants: { 'content.update': 'all' },
|
||||||
|
initial: { 'content.update': 'own' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
wrapper!.find('[data-testid="row-events"] [data-testid="changed-marker"]').exists(),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('locks Publish All with a lock reason when the caller can publish only their own', () => {
|
||||||
|
mountGrid({ callerScope: callerFor('content.read, content.publish=own') })
|
||||||
|
|
||||||
|
const radios = radiosOf(wrapper!, 'Events: Publish')
|
||||||
|
const all = radios[2]!
|
||||||
|
expect(all.getAttribute('aria-disabled')).toBe('true')
|
||||||
|
expect(document.getElementById(all.getAttribute('aria-describedby')!)?.textContent).toBe(
|
||||||
|
"You can only grant what your role has. Your role can publish only entries you created, so All isn't available.",
|
||||||
|
)
|
||||||
|
expect(radios[0]!.getAttribute('aria-disabled')).toBeNull()
|
||||||
|
expect(radios[1]!.getAttribute('aria-disabled')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('locks the matching cell for a typed override in the session', () => {
|
||||||
|
mountGrid({
|
||||||
|
callerScope: callerFor('content.read, content.update, content.update:article=none'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(radiosOf(wrapper!, 'Articles: Update')[1]!.getAttribute('aria-disabled')).toBe(
|
||||||
|
'true',
|
||||||
|
)
|
||||||
|
expect(radiosOf(wrapper!, 'Events: Update')[1]!.getAttribute('aria-disabled')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables every control when read-only', () => {
|
||||||
|
mountGrid({ readOnly: true })
|
||||||
|
|
||||||
|
for (const radio of radiosOf(wrapper!, 'Events: Read')) {
|
||||||
|
expect(radio.getAttribute('aria-disabled')).toBe('true')
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
wrapper!.find('[role="switch"][aria-label="Events: Create"]').attributes('disabled'),
|
||||||
|
).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('draws one card per type on mobile with the same controls', () => {
|
||||||
|
mountGrid({ mobile: true })
|
||||||
|
|
||||||
|
expect(wrapper!.find('[role="grid"]').exists()).toBe(false)
|
||||||
|
expect(wrapper!.find('[data-testid="card-events"]').exists()).toBe(true)
|
||||||
|
expect(radiosOf(wrapper!, 'Events: Update')).toHaveLength(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('PresetPills', () => {
|
||||||
|
it('lists the three presets and presses the active one', async () => {
|
||||||
|
const chosen: string[] = []
|
||||||
|
wrapper = renderInApp(PresetPills, {
|
||||||
|
active: 'write_own',
|
||||||
|
onSelect: (preset: string) => chosen.push(preset),
|
||||||
|
})
|
||||||
|
|
||||||
|
const buttons = wrapper.findAll('button')
|
||||||
|
expect(buttons.map((button) => button.text())).toEqual([
|
||||||
|
'Read only',
|
||||||
|
'Write own',
|
||||||
|
'Full content',
|
||||||
|
])
|
||||||
|
expect(buttons.map((button) => button.attributes('aria-pressed'))).toEqual([
|
||||||
|
'false',
|
||||||
|
'true',
|
||||||
|
'false',
|
||||||
|
])
|
||||||
|
await buttons[2]!.trigger('click')
|
||||||
|
|
||||||
|
expect(chosen).toEqual(['full_content'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import PermissionGroup from '../PermissionGroup.vue'
|
||||||
|
import { radiosOf, renderInApp } from './roleKit'
|
||||||
|
import { effectiveScope } from '@/lib/permissions'
|
||||||
|
import { grantsFrom } from '@/test/access'
|
||||||
|
import type { PermissionGroupItem } from '@/lib/roles'
|
||||||
|
|
||||||
|
let wrapper: VueWrapper | null = null
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
wrapper = null
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const ACTIVITY: PermissionGroupItem = {
|
||||||
|
key: 'activity',
|
||||||
|
items: [
|
||||||
|
{ key: 'activity.view', scope_kind: 'scoped', owner_only: false, per_type: false },
|
||||||
|
{ key: 'activity.export', scope_kind: 'toggle', owner_only: false, per_type: false },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYSTEM: PermissionGroupItem = {
|
||||||
|
key: 'system',
|
||||||
|
items: [
|
||||||
|
{ key: 'system.apply_updates', scope_kind: 'toggle', owner_only: true, per_type: false },
|
||||||
|
{ key: 'system.edit_retention', scope_kind: 'toggle', owner_only: true, per_type: false },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const CALLER = (spec: string) => {
|
||||||
|
const grants = grantsFrom(spec)
|
||||||
|
return (key: string, options?: { typeKey?: string }) => effectiveScope(grants, key, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountGroup(group: PermissionGroupItem, props: Record<string, unknown> = {}) {
|
||||||
|
const changes: { key: string; scope: string }[] = []
|
||||||
|
wrapper = renderInApp(PermissionGroup, {
|
||||||
|
group,
|
||||||
|
grants: { 'activity.view': 'all', 'activity.export': 'all' },
|
||||||
|
initial: { 'activity.view': 'all', 'activity.export': 'all' },
|
||||||
|
callerScope: CALLER('activity.view, activity.export, system.apply_updates'),
|
||||||
|
open: true,
|
||||||
|
onChange: (change: { key: string; scope: string }) => changes.push(change),
|
||||||
|
...props,
|
||||||
|
})
|
||||||
|
return { changes }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PermissionGroup', () => {
|
||||||
|
it('reads "x of y enabled" from the grants', () => {
|
||||||
|
mountGroup(ACTIVITY)
|
||||||
|
|
||||||
|
expect(wrapper!.text()).toContain('2 of 2 enabled')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts only the enabled items', () => {
|
||||||
|
mountGroup(ACTIVITY, { grants: { 'activity.view': 'own' } })
|
||||||
|
|
||||||
|
expect(wrapper!.text()).toContain('1 of 2 enabled')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the human names and descriptions of the design', () => {
|
||||||
|
mountGroup(ACTIVITY)
|
||||||
|
|
||||||
|
const text = wrapper!.text()
|
||||||
|
expect(text).toContain('See activity')
|
||||||
|
expect(text).toContain('Read the log of who did what.')
|
||||||
|
expect(text).toContain('Export activity')
|
||||||
|
expect(text).toContain('Download the log as a CSV file.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders scoped rows as None, Own, All and toggle rows as a switch', async () => {
|
||||||
|
const { changes } = mountGroup(ACTIVITY)
|
||||||
|
|
||||||
|
expect(radiosOf(wrapper!, 'See activity')).toHaveLength(3)
|
||||||
|
expect(wrapper!.find('[role="switch"][aria-label="Export activity"]').exists()).toBe(true)
|
||||||
|
|
||||||
|
radiosOf(wrapper!, 'See activity')[1]!.click()
|
||||||
|
await wrapper!.find('[role="switch"][aria-label="Export activity"]').trigger('click')
|
||||||
|
|
||||||
|
expect(changes).toEqual([
|
||||||
|
{ key: 'activity.view', scope: 'own' },
|
||||||
|
{ key: 'activity.export', scope: 'none' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('announces a change in a polite live region', async () => {
|
||||||
|
mountGroup(ACTIVITY)
|
||||||
|
|
||||||
|
radiosOf(wrapper!, 'See activity')[1]!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const live = wrapper!.find('[role="status"][aria-live="polite"]')
|
||||||
|
expect(live.text()).toBe('See activity, set to Own.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides the rows while collapsed', () => {
|
||||||
|
mountGroup(ACTIVITY, { open: false })
|
||||||
|
|
||||||
|
expect(wrapper!.text()).not.toContain('Read the log of who did what.')
|
||||||
|
expect(wrapper!.text()).toContain('2 of 2 enabled')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables owner-only items, keeps them off and tags them Owner only', async () => {
|
||||||
|
const { changes } = mountGroup(SYSTEM, { grants: {}, initial: {} })
|
||||||
|
|
||||||
|
expect(wrapper!.findAll('[data-testid="owner-only-tag"]')).toHaveLength(2)
|
||||||
|
const toggle = wrapper!.find('[role="switch"][aria-label="Apply software updates"]')
|
||||||
|
expect(toggle.attributes('aria-checked')).toBe('false')
|
||||||
|
expect(toggle.attributes('disabled')).toBeDefined()
|
||||||
|
await toggle.trigger('click')
|
||||||
|
|
||||||
|
expect(changes).toEqual([])
|
||||||
|
expect(wrapper!.text()).toContain('0 of 2 enabled')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('locks All on a scoped row the caller holds only as Own', () => {
|
||||||
|
mountGroup(ACTIVITY, { callerScope: CALLER('activity.view=own, activity.export') })
|
||||||
|
|
||||||
|
const all = radiosOf(wrapper!, 'See activity')[2]!
|
||||||
|
expect(all.getAttribute('aria-disabled')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables everything when read-only', () => {
|
||||||
|
mountGroup(ACTIVITY, { readOnly: true })
|
||||||
|
|
||||||
|
for (const radio of radiosOf(wrapper!, 'See activity')) {
|
||||||
|
expect(radio.getAttribute('aria-disabled')).toBe('true')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import RolePreview from '../RolePreview.vue'
|
||||||
|
import { renderInApp } from './roleKit'
|
||||||
|
import { applyPreset } from '@/lib/roleForm'
|
||||||
|
import { buildRolePreview } from '@/lib/rolePreview'
|
||||||
|
|
||||||
|
let wrapper: VueWrapper | null = null
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
wrapper = null
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const TYPES = [{ key: 'article', name: 'Articles' }]
|
||||||
|
|
||||||
|
function model(changed = false) {
|
||||||
|
const initial = applyPreset({}, 'write_own')
|
||||||
|
const grants = changed ? { ...initial, 'content.update': 'all' as const } : initial
|
||||||
|
return buildRolePreview({ grants, types: TYPES, members: 4, initial })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RolePreview card', () => {
|
||||||
|
it('titles the card and says it updates as you edit', () => {
|
||||||
|
wrapper = renderInApp(RolePreview, { model: model() })
|
||||||
|
|
||||||
|
const text = wrapper.text()
|
||||||
|
expect(text).toContain('What this role can do')
|
||||||
|
expect(text).toContain('Updates as you edit.')
|
||||||
|
expect(text).toContain('Content')
|
||||||
|
expect(text).toContain('All content types')
|
||||||
|
expect(text).toContain('Applies to 4 members, and to the API keys they created.')
|
||||||
|
expect(text).toContain(
|
||||||
|
'No access to Schema, Media, Authors, Activity, Users and invites, Settings, Roles.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tags the changed line', () => {
|
||||||
|
wrapper = renderInApp(RolePreview, { model: model(true) })
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Changed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads a summary without "Updates as you edit." when read-only', () => {
|
||||||
|
wrapper = renderInApp(RolePreview, { model: model(), readOnly: true })
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('A summary of what this role allows.')
|
||||||
|
expect(wrapper.text()).not.toContain('Updates as you edit.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('RolePreview mobile sheet', () => {
|
||||||
|
it('opens a bottom sheet with a Close button that closes it', async () => {
|
||||||
|
const states: boolean[] = []
|
||||||
|
wrapper = renderInApp(RolePreview, {
|
||||||
|
model: model(),
|
||||||
|
mobile: true,
|
||||||
|
open: true,
|
||||||
|
'onUpdate:open': (value: boolean) => states.push(value),
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const sheet = document.body.querySelector('[role="dialog"]')!
|
||||||
|
expect(sheet.textContent).toContain('What this role can do')
|
||||||
|
expect(sheet.textContent).toContain('Updates as you edit.')
|
||||||
|
const close = Array.from(sheet.querySelectorAll('button')).find(
|
||||||
|
(button) => button.textContent?.trim() === 'Close',
|
||||||
|
)!
|
||||||
|
close.click()
|
||||||
|
|
||||||
|
expect(states).toEqual([false])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing while closed', async () => {
|
||||||
|
wrapper = renderInApp(RolePreview, { model: model(), mobile: true, open: false })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import ScopeControl from '../ScopeControl.vue'
|
||||||
|
import OnOffCell from '../OnOffCell.vue'
|
||||||
|
import { press, radiosOf, renderInApp } from './roleKit'
|
||||||
|
import type { Scope } from '@/lib/permissions'
|
||||||
|
|
||||||
|
let wrapper: VueWrapper | null = null
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
wrapper = null
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function mountControl(initial: Scope, extra: Record<string, unknown> = {}) {
|
||||||
|
const value = ref<Scope>(initial)
|
||||||
|
const changes: Scope[] = []
|
||||||
|
wrapper = renderInApp(ScopeControl, {
|
||||||
|
label: 'Events: Update',
|
||||||
|
modelValue: initial,
|
||||||
|
'onUpdate:modelValue': (scope: Scope) => {
|
||||||
|
changes.push(scope)
|
||||||
|
value.value = scope
|
||||||
|
},
|
||||||
|
...extra,
|
||||||
|
})
|
||||||
|
return { changes }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ScopeControl', () => {
|
||||||
|
it('is a radiogroup named after the type and action with None, Own and All', () => {
|
||||||
|
mountControl('own')
|
||||||
|
|
||||||
|
const radios = radiosOf(wrapper!, 'Events: Update')
|
||||||
|
expect(radios.map((radio) => radio.textContent?.trim())).toEqual(['None', 'Own', 'All'])
|
||||||
|
expect(radios.map((radio) => radio.getAttribute('aria-checked'))).toEqual([
|
||||||
|
'false',
|
||||||
|
'true',
|
||||||
|
'false',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has one tab stop: the checked radio', () => {
|
||||||
|
mountControl('own')
|
||||||
|
|
||||||
|
expect(radiosOf(wrapper!, 'Events: Update').map((radio) => radio.tabIndex)).toEqual([
|
||||||
|
-1, 0, -1,
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the checked radio as the single tab stop when the whole control is disabled', () => {
|
||||||
|
mountControl('own', { disabled: true })
|
||||||
|
|
||||||
|
const radios = radiosOf(wrapper!, 'Events: Update')
|
||||||
|
expect(radios.map((radio) => radio.tabIndex)).toEqual([-1, 0, -1])
|
||||||
|
expect(radios.map((radio) => radio.getAttribute('aria-disabled'))).toEqual([
|
||||||
|
'true',
|
||||||
|
'true',
|
||||||
|
'true',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the first option as tab stop when disabled and nothing matches', () => {
|
||||||
|
mountControl('none', { disabled: true })
|
||||||
|
|
||||||
|
expect(radiosOf(wrapper!, 'Events: Update').map((radio) => radio.tabIndex)).toEqual([
|
||||||
|
0, -1, -1,
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves the tab stop off a locked checked option only when an enabled one exists', () => {
|
||||||
|
mountControl('all', { locks: { all: 'Locked' } })
|
||||||
|
|
||||||
|
expect(radiosOf(wrapper!, 'Events: Update').map((radio) => radio.tabIndex)).toEqual([
|
||||||
|
0, -1, -1,
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects with a click', async () => {
|
||||||
|
const { changes } = mountControl('own')
|
||||||
|
|
||||||
|
radiosOf(wrapper!, 'Events: Update')[2]!.click()
|
||||||
|
|
||||||
|
expect(changes).toEqual(['all'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['ArrowRight', 'own', 'all'],
|
||||||
|
['ArrowDown', 'own', 'all'],
|
||||||
|
['ArrowLeft', 'own', 'none'],
|
||||||
|
['ArrowUp', 'own', 'none'],
|
||||||
|
['Home', 'all', 'none'],
|
||||||
|
['End', 'none', 'all'],
|
||||||
|
] as const)('%s moves from %s to %s', async (key, from, to) => {
|
||||||
|
const { changes } = mountControl(from)
|
||||||
|
const current = radiosOf(wrapper!, 'Events: Update').find(
|
||||||
|
(radio) => radio.getAttribute('aria-checked') === 'true',
|
||||||
|
)!
|
||||||
|
current.focus()
|
||||||
|
|
||||||
|
press(current, key)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(changes).toEqual([to])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects the focused radio with Space', async () => {
|
||||||
|
const { changes } = mountControl('none')
|
||||||
|
const all = radiosOf(wrapper!, 'Events: Update')[2]!
|
||||||
|
all.focus()
|
||||||
|
|
||||||
|
press(all, ' ')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(changes).toEqual(['all'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a locked option aria-disabled, describes it by its reason and does not select it', async () => {
|
||||||
|
const reason = 'You can only grant what your role has.'
|
||||||
|
const { changes } = mountControl('own', { locks: { all: reason } })
|
||||||
|
|
||||||
|
const all = radiosOf(wrapper!, 'Events: Update')[2]!
|
||||||
|
all.click()
|
||||||
|
const described = document.getElementById(all.getAttribute('aria-describedby')!)
|
||||||
|
|
||||||
|
expect(all.getAttribute('aria-disabled')).toBe('true')
|
||||||
|
expect(described?.textContent).toBe(reason)
|
||||||
|
expect(changes).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips locked options with the keyboard and keeps None and Own available', async () => {
|
||||||
|
const { changes } = mountControl('none', { locks: { all: 'x' } })
|
||||||
|
const none = radiosOf(wrapper!, 'Events: Update')[0]!
|
||||||
|
none.focus()
|
||||||
|
|
||||||
|
press(none, 'End')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(changes).toEqual(['own'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables every option when the control is disabled', () => {
|
||||||
|
mountControl('own', { disabled: true })
|
||||||
|
|
||||||
|
for (const radio of radiosOf(wrapper!, 'Events: Update')) {
|
||||||
|
expect(radio.getAttribute('aria-disabled')).toBe('true')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('draws a square marker on a changed cell', () => {
|
||||||
|
mountControl('own', { changed: true })
|
||||||
|
|
||||||
|
expect(wrapper!.find('[data-testid="changed-marker"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('OnOffCell', () => {
|
||||||
|
it('is a switch named "{Type}: Create" with a visible On or Off label', () => {
|
||||||
|
wrapper = renderInApp(OnOffCell, { label: 'Events: Create', modelValue: true })
|
||||||
|
|
||||||
|
const toggle = wrapper.find('[role="switch"]')
|
||||||
|
expect(toggle.attributes('aria-label')).toBe('Events: Create')
|
||||||
|
expect(toggle.attributes('aria-checked')).toBe('true')
|
||||||
|
expect(wrapper.text()).toContain('On')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says Off when off and emits the new value on click', async () => {
|
||||||
|
const seen: boolean[] = []
|
||||||
|
wrapper = renderInApp(OnOffCell, {
|
||||||
|
label: 'Events: Create',
|
||||||
|
modelValue: false,
|
||||||
|
'onUpdate:modelValue': (value: boolean) => seen.push(value),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Off')
|
||||||
|
await wrapper.find('[role="switch"]').trigger('click')
|
||||||
|
|
||||||
|
expect(seen).toEqual([true])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the lock reason and cannot be toggled when locked', async () => {
|
||||||
|
const seen: boolean[] = []
|
||||||
|
wrapper = renderInApp(OnOffCell, {
|
||||||
|
label: 'Events: Create',
|
||||||
|
modelValue: false,
|
||||||
|
disabled: true,
|
||||||
|
reason: 'Only the owner can grant this.',
|
||||||
|
'onUpdate:modelValue': (value: boolean) => seen.push(value),
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.find('[role="switch"]').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Only the owner can grant this.')
|
||||||
|
expect(seen).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { h } from 'vue'
|
||||||
|
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||||
|
import UApp from '@nuxt/ui/components/App.vue'
|
||||||
|
import router from '@/router'
|
||||||
|
|
||||||
|
export function renderInApp(component: object, props: Record<string, unknown> = {}) {
|
||||||
|
return mount(() => h(UApp, null, () => h(component, props)), {
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { plugins: [router, uiPlugin] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function radiosOf(root: VueWrapper | Element, name: string) {
|
||||||
|
const element = 'element' in root ? root.element : root
|
||||||
|
const group = element.querySelector(`[role="radiogroup"][aria-label="${name}"]`)
|
||||||
|
return Array.from(group?.querySelectorAll<HTMLElement>('[role="radio"]') ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function press(target: Element, key: string) {
|
||||||
|
target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ApiError, useApi } from '@/composables/useApi'
|
||||||
|
import { RoleConflictError, useRoles } from '@/composables/useRoles'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
const request = vi.fn()
|
||||||
|
|
||||||
|
function role(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: 4,
|
||||||
|
name: 'Contributor',
|
||||||
|
description: '',
|
||||||
|
built_in: false,
|
||||||
|
locked: false,
|
||||||
|
version: 2,
|
||||||
|
grants: [],
|
||||||
|
members: 4,
|
||||||
|
updated_at: '2026-10-27T11:02:00Z',
|
||||||
|
updated_by: { id: 2, name: 'Jonas Weber' },
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
request.mockReset()
|
||||||
|
vi.mocked(useApi).mockReturnValue({ request } as unknown as ReturnType<typeof useApi>)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useRoles calls', () => {
|
||||||
|
it('lists and gets roles', async () => {
|
||||||
|
request.mockResolvedValue([])
|
||||||
|
await useRoles().listRoles()
|
||||||
|
await useRoles().getRole(4)
|
||||||
|
|
||||||
|
expect(request.mock.calls[0]![0]).toBe('/v1/role/')
|
||||||
|
expect(request.mock.calls[1]![0]).toBe('/v1/role/4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads the permission groups out of the catalogue body', async () => {
|
||||||
|
request.mockResolvedValue({ groups: [{ key: 'content', items: [] }] })
|
||||||
|
|
||||||
|
await expect(useRoles().listPermissions()).resolves.toEqual([{ key: 'content', items: [] }])
|
||||||
|
expect(request.mock.calls[0]![0]).toBe('/v1/permission/')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates with name, description and grants only', async () => {
|
||||||
|
request.mockResolvedValue(role())
|
||||||
|
const payload = {
|
||||||
|
name: 'Contributor',
|
||||||
|
description: 'x',
|
||||||
|
grants: [{ key: 'content.read', scope: 'all' as const }],
|
||||||
|
}
|
||||||
|
|
||||||
|
await useRoles().createRole(payload)
|
||||||
|
|
||||||
|
const [path, options] = request.mock.calls[0]!
|
||||||
|
expect(path).toBe('/v1/role/')
|
||||||
|
expect(options.method).toBe('POST')
|
||||||
|
expect(JSON.parse(options.body)).toEqual(payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates with the version', async () => {
|
||||||
|
request.mockResolvedValue(role())
|
||||||
|
|
||||||
|
await useRoles().updateRole(4, { name: 'C', description: '', grants: [] }, 2)
|
||||||
|
|
||||||
|
const [path, options] = request.mock.calls[0]!
|
||||||
|
expect(path).toBe('/v1/role/4')
|
||||||
|
expect(options.method).toBe('PUT')
|
||||||
|
expect(JSON.parse(options.body)).toEqual({
|
||||||
|
name: 'C',
|
||||||
|
description: '',
|
||||||
|
grants: [],
|
||||||
|
version: 2,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets with the version', async () => {
|
||||||
|
request.mockResolvedValue(role())
|
||||||
|
|
||||||
|
await useRoles().resetRole(3, 5)
|
||||||
|
|
||||||
|
const [path, options] = request.mock.calls[0]!
|
||||||
|
expect(path).toBe('/v1/role/3/reset')
|
||||||
|
expect(JSON.parse(options.body)).toEqual({ version: 5 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reassigns then deletes', async () => {
|
||||||
|
request.mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
await useRoles().reassignRole(4, 3)
|
||||||
|
await useRoles().deleteRole(4)
|
||||||
|
|
||||||
|
expect(request.mock.calls[0]![0]).toBe('/v1/role/4/reassign')
|
||||||
|
expect(JSON.parse(request.mock.calls[0]![1].body)).toEqual({ target_role_id: 3 })
|
||||||
|
expect(request.mock.calls[1]![0]).toBe('/v1/role/4')
|
||||||
|
expect(request.mock.calls[1]![1].method).toBe('DELETE')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not toast a missing permission itself', async () => {
|
||||||
|
request.mockResolvedValue([])
|
||||||
|
await useRoles().listRoles()
|
||||||
|
|
||||||
|
expect(request.mock.calls[0]![1]).toMatchObject({ onMissingPermission: 'passthrough' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('role_conflict', () => {
|
||||||
|
it('re-reads the role on a conflicting update and throws it with the editor', async () => {
|
||||||
|
const latest = role({ version: 3 })
|
||||||
|
request
|
||||||
|
.mockRejectedValueOnce(
|
||||||
|
new ApiError(409, undefined, 'role_conflict', { code: 'role_conflict' }),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(latest)
|
||||||
|
|
||||||
|
const failure = await useRoles()
|
||||||
|
.updateRole(4, { name: 'C', description: '', grants: [] }, 2)
|
||||||
|
.catch((error: unknown) => error)
|
||||||
|
|
||||||
|
expect(failure).toBeInstanceOf(RoleConflictError)
|
||||||
|
expect((failure as RoleConflictError).latest?.updated_by?.name).toBe('Jonas Weber')
|
||||||
|
expect(request.mock.calls[1]![0]).toBe('/v1/role/4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets every other refusal through unchanged', async () => {
|
||||||
|
const refusal = new ApiError(409, undefined, 'role_name_taken', { code: 'role_name_taken' })
|
||||||
|
request.mockRejectedValue(refusal)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
useRoles().updateRole(4, { name: 'C', description: '', grants: [] }, 2),
|
||||||
|
).rejects.toBe(refusal)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('conflicts on reset too', async () => {
|
||||||
|
request
|
||||||
|
.mockRejectedValueOnce(
|
||||||
|
new ApiError(409, undefined, 'role_conflict', { code: 'role_conflict' }),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(role())
|
||||||
|
|
||||||
|
await expect(useRoles().resetRole(4, 1)).rejects.toBeInstanceOf(RoleConflictError)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
import { t, locale } from '@/i18n'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useLocalizedToast } from '@/i18n/presentation'
|
||||||
|
import { ApiError } from '@/composables/useApi'
|
||||||
|
import { RoleConflictError, useRoles } from '@/composables/useRoles'
|
||||||
|
import { refusalCopy, type RefusalCopy } from '@/lib/permissionCopy'
|
||||||
|
import { permissionName } from '@/lib/permissionNames'
|
||||||
|
import {
|
||||||
|
applyPreset,
|
||||||
|
cloneForm,
|
||||||
|
differences,
|
||||||
|
emptyForm,
|
||||||
|
formFromRole,
|
||||||
|
grantsToPayload,
|
||||||
|
resetRow,
|
||||||
|
setScope,
|
||||||
|
unsavedCount,
|
||||||
|
activePreset,
|
||||||
|
contentKey,
|
||||||
|
type ContentAction,
|
||||||
|
type PresetName,
|
||||||
|
type RoleForm,
|
||||||
|
} from '@/lib/roleForm'
|
||||||
|
import { buildRolePreview } from '@/lib/rolePreview'
|
||||||
|
import {
|
||||||
|
OWNER_ONLY_FALLBACK,
|
||||||
|
formatClock,
|
||||||
|
type PermissionGroupItem,
|
||||||
|
type RoleItem,
|
||||||
|
} from '@/lib/roles'
|
||||||
|
import { ARTICLE_TYPE_KEY } from '@/lib/routeAccess'
|
||||||
|
import { parseGrantKey, type Scope } from '@/lib/permissions'
|
||||||
|
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||||
|
import { usePermissionsStore } from '@/stores/permissions'
|
||||||
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
|
||||||
|
export type LoadState = 'loading' | 'ready' | 'error' | 'missing'
|
||||||
|
|
||||||
|
export type ReadOnlyReason = 'owner' | 'lowered' | 'view' | 'own' | 'builtin' | 'locked'
|
||||||
|
|
||||||
|
export interface ConflictState {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
latest: RoleItem | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_CONFLICT = null
|
||||||
|
|
||||||
|
export function useRoleEditor() {
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useLocalizedToast()
|
||||||
|
const permissions = usePermissionsStore()
|
||||||
|
const session = useSessionStore()
|
||||||
|
const contentTypes = useContentTypesStore()
|
||||||
|
const api = useRoles()
|
||||||
|
|
||||||
|
const loadState = ref<LoadState>('loading')
|
||||||
|
const role = ref<RoleItem | null>(null)
|
||||||
|
const form = ref<RoleForm>(emptyForm())
|
||||||
|
const initial = ref<RoleForm>(emptyForm())
|
||||||
|
const catalogue = ref<PermissionGroupItem[]>([])
|
||||||
|
const saving = ref(false)
|
||||||
|
const nameError = ref('')
|
||||||
|
const conflict = ref<ConflictState | null>(NO_CONFLICT)
|
||||||
|
const lowered = ref(false)
|
||||||
|
const serverLocked = ref(false)
|
||||||
|
const serverOwnRole = ref(false)
|
||||||
|
const refusal = ref<RefusalCopy | null>(null)
|
||||||
|
const reviewing = ref(false)
|
||||||
|
let loadedKey = ''
|
||||||
|
let loadToken = 0
|
||||||
|
|
||||||
|
const isNew = computed(() => route.params.id === undefined)
|
||||||
|
|
||||||
|
const ownerOnlyKeys = computed(() => {
|
||||||
|
const keys = catalogue.value.flatMap((group) =>
|
||||||
|
group.items.filter((item) => item.owner_only).map((item) => item.key),
|
||||||
|
)
|
||||||
|
return keys.length > 0 ? new Set(keys) : OWNER_ONLY_FALLBACK
|
||||||
|
})
|
||||||
|
|
||||||
|
const readOnlyReason = computed<ReadOnlyReason | null>(() => {
|
||||||
|
if (role.value?.locked) {
|
||||||
|
return 'owner'
|
||||||
|
}
|
||||||
|
if (lowered.value) {
|
||||||
|
return 'lowered'
|
||||||
|
}
|
||||||
|
if (!permissions.can('roles.manage')) {
|
||||||
|
return 'view'
|
||||||
|
}
|
||||||
|
if (serverOwnRole.value || (role.value && role.value.id === permissions.role?.id)) {
|
||||||
|
return 'own'
|
||||||
|
}
|
||||||
|
if (role.value?.built_in && !permissions.can('roles.edit_owner_only')) {
|
||||||
|
return 'builtin'
|
||||||
|
}
|
||||||
|
return serverLocked.value ? 'locked' : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const readOnly = computed(() => readOnlyReason.value !== null)
|
||||||
|
const unsaved = computed(() => unsavedCount(form.value, initial.value))
|
||||||
|
const problems = computed(() => (nameError.value === '' ? 0 : 1))
|
||||||
|
const nameMissing = computed(() => form.value.name.trim() === '')
|
||||||
|
const canSave = computed(
|
||||||
|
() =>
|
||||||
|
!readOnly.value &&
|
||||||
|
!saving.value &&
|
||||||
|
conflict.value === null &&
|
||||||
|
!(isNew.value && nameMissing.value),
|
||||||
|
)
|
||||||
|
const barVisible = computed(
|
||||||
|
() =>
|
||||||
|
!readOnly.value &&
|
||||||
|
loadState.value === 'ready' &&
|
||||||
|
(isNew.value ||
|
||||||
|
unsaved.value > 0 ||
|
||||||
|
saving.value ||
|
||||||
|
problems.value > 0 ||
|
||||||
|
conflict.value !== null),
|
||||||
|
)
|
||||||
|
const preset = computed(() => activePreset(form.value.grants))
|
||||||
|
const canReset = computed(
|
||||||
|
() =>
|
||||||
|
role.value !== null &&
|
||||||
|
role.value.built_in &&
|
||||||
|
!role.value.locked &&
|
||||||
|
permissions.can('roles.manage') &&
|
||||||
|
permissions.can('roles.edit_owner_only') &&
|
||||||
|
!readOnly.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
const gridTypes = computed(() => {
|
||||||
|
const named = [
|
||||||
|
{ key: ARTICLE_TYPE_KEY, name: t('Articles'), builtin: true },
|
||||||
|
...contentTypes.items.map((type) => ({
|
||||||
|
key: type.key,
|
||||||
|
name: type.name,
|
||||||
|
builtin: false,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
const known = new Set(named.map((type) => type.key))
|
||||||
|
const typed = Object.keys(form.value.grants)
|
||||||
|
.map((key) => parseGrantKey(key))
|
||||||
|
.filter(({ base }) => base.startsWith('content.'))
|
||||||
|
.map(({ typeKey }) => typeKey)
|
||||||
|
.filter((key): key is string => key !== undefined)
|
||||||
|
.filter((key, index, all) => !known.has(key) && all.indexOf(key) === index)
|
||||||
|
.map((key) => ({ key, name: key, builtin: false }))
|
||||||
|
return [...named, ...typed]
|
||||||
|
})
|
||||||
|
|
||||||
|
const preview = computed(() =>
|
||||||
|
buildRolePreview({
|
||||||
|
grants: form.value.grants,
|
||||||
|
types: gridTypes.value.map((type) => ({ key: type.key, name: type.name })),
|
||||||
|
members: role.value?.members ?? null,
|
||||||
|
initial: isNew.value || readOnly.value ? undefined : initial.value.grants,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const differenceRows = computed(() => {
|
||||||
|
const latest = conflict.value?.latest
|
||||||
|
if (!latest) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return differences(form.value, formFromRole(latest)).map((entry) => ({
|
||||||
|
name: permissionName(entry.key) ?? entry.key,
|
||||||
|
key: entry.key,
|
||||||
|
kind:
|
||||||
|
entry.before === undefined || entry.before === 'none'
|
||||||
|
? ('added' as const)
|
||||||
|
: entry.after === undefined || entry.after === 'none'
|
||||||
|
? ('removed' as const)
|
||||||
|
: ('changed' as const),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
function adopt(next: RoleItem) {
|
||||||
|
role.value = next
|
||||||
|
form.value = formFromRole(next)
|
||||||
|
initial.value = cloneForm(form.value)
|
||||||
|
nameError.value = ''
|
||||||
|
conflict.value = NO_CONFLICT
|
||||||
|
reviewing.value = false
|
||||||
|
refusal.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeKey() {
|
||||||
|
return isNew.value ? `new:${route.query.from ?? ''}` : String(route.params.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCatalogue() {
|
||||||
|
try {
|
||||||
|
catalogue.value = await api.listPermissions()
|
||||||
|
} catch {
|
||||||
|
catalogue.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSource(token: number) {
|
||||||
|
if (isNew.value) {
|
||||||
|
const from = Number(route.query.from)
|
||||||
|
if (route.query.from === undefined || !Number.isInteger(from)) {
|
||||||
|
role.value = null
|
||||||
|
form.value = emptyForm()
|
||||||
|
initial.value = emptyForm()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const source = await api.getRole(from)
|
||||||
|
if (token !== loadToken) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
role.value = null
|
||||||
|
form.value = { name: '', description: '', grants: formFromRole(source).grants }
|
||||||
|
initial.value = cloneForm(form.value)
|
||||||
|
loadedKey = 'new:'
|
||||||
|
await router.replace({ path: '/settings/roles/new' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = Number(route.params.id)
|
||||||
|
if (!Number.isInteger(id)) {
|
||||||
|
loadState.value = 'missing'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const fetched = await api.getRole(id)
|
||||||
|
if (token !== loadToken) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adopt(fetched)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loadedKey = routeKey()
|
||||||
|
const token = ++loadToken
|
||||||
|
loadState.value = 'loading'
|
||||||
|
lowered.value = false
|
||||||
|
serverLocked.value = false
|
||||||
|
serverOwnRole.value = false
|
||||||
|
conflict.value = NO_CONFLICT
|
||||||
|
const pending = [loadCatalogue()]
|
||||||
|
if (permissions.can('schema.view')) {
|
||||||
|
pending.push(contentTypes.load())
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Promise.all([loadSource(token), ...pending])
|
||||||
|
if (token !== loadToken) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (loadState.value === 'loading') {
|
||||||
|
loadState.value = 'ready'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (token !== loadToken) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (error instanceof ApiError && error.code === 'role_not_found') {
|
||||||
|
loadState.value = 'missing'
|
||||||
|
} else if (error instanceof ApiError && error.code === 'missing_permission') {
|
||||||
|
lowered.value = true
|
||||||
|
loadState.value = 'ready'
|
||||||
|
} else {
|
||||||
|
loadState.value = 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [route.params.id, route.query.from],
|
||||||
|
() => {
|
||||||
|
if (routeKey() !== loadedKey) {
|
||||||
|
void load()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function changeCell(change: { action: ContentAction; scope: Scope; typeKey?: string }) {
|
||||||
|
form.value = {
|
||||||
|
...form.value,
|
||||||
|
grants: setScope(
|
||||||
|
form.value.grants,
|
||||||
|
contentKey(change.action),
|
||||||
|
change.scope,
|
||||||
|
change.typeKey,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeGeneral(change: { key: string; scope: Scope }) {
|
||||||
|
form.value = {
|
||||||
|
...form.value,
|
||||||
|
grants: setScope(form.value.grants, change.key, change.scope),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function choosePreset(next: PresetName) {
|
||||||
|
form.value = { ...form.value, grants: applyPreset(form.value.grants, next) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetType(typeKey: string) {
|
||||||
|
form.value = { ...form.value, grants: resetRow(form.value.grants, typeKey) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function setName(name: string) {
|
||||||
|
form.value = { ...form.value, name }
|
||||||
|
nameError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDescription(description: string) {
|
||||||
|
form.value = { ...form.value, description }
|
||||||
|
}
|
||||||
|
|
||||||
|
function discard() {
|
||||||
|
form.value = cloneForm(initial.value)
|
||||||
|
nameError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function conflictFrom(latest: RoleItem | null): ConflictState {
|
||||||
|
return {
|
||||||
|
title: t('This role changed while you were editing'),
|
||||||
|
description: t(
|
||||||
|
'{0} saved changes to {1} at {2}. Reload to see them, or review how they differ from your edits.',
|
||||||
|
[
|
||||||
|
latest?.updated_by?.name ?? t('Someone'),
|
||||||
|
latest?.name ?? form.value.name,
|
||||||
|
formatClock(latest?.updated_at, locale.value),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
latest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadRole() {
|
||||||
|
if (conflict.value?.latest) {
|
||||||
|
adopt(conflict.value.latest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadPage() {
|
||||||
|
await session.refresh()
|
||||||
|
lowered.value = false
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toastSuccess(id: string, title: string, description: string) {
|
||||||
|
toast.add(() => ({
|
||||||
|
id,
|
||||||
|
color: 'success',
|
||||||
|
icon: 'i-lucide-circle-check',
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toastFailure() {
|
||||||
|
toast.add(() => ({
|
||||||
|
id: 'role-save-failed',
|
||||||
|
color: 'error',
|
||||||
|
icon: 'i-lucide-circle-x',
|
||||||
|
title: t("Couldn't save the role"),
|
||||||
|
description: t('Try again in a moment.'),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function memberCountCopy(saved: RoleItem) {
|
||||||
|
return saved.members === 1
|
||||||
|
? t('{0} is updated for 1 member.', [saved.name])
|
||||||
|
: t('{0} is updated for {1} members.', [saved.name, saved.members])
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRefusal(error: ApiError) {
|
||||||
|
const copy =
|
||||||
|
error.code === undefined ? undefined : refusalCopy(error.code, error.permission)
|
||||||
|
if (copy === undefined) {
|
||||||
|
toastFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refusal.value = error.escalationKey
|
||||||
|
? {
|
||||||
|
title: copy.title,
|
||||||
|
description:
|
||||||
|
`${copy.description ?? ''} ${permissionName(error.escalationKey) ?? error.escalationKey}`.trim(),
|
||||||
|
}
|
||||||
|
: copy
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveError(error: unknown) {
|
||||||
|
if (error instanceof RoleConflictError) {
|
||||||
|
conflict.value = conflictFrom(error.latest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!(error instanceof ApiError)) {
|
||||||
|
toastFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch (error.code) {
|
||||||
|
case 'invalid_role_name':
|
||||||
|
nameError.value = t('Give the role a name.')
|
||||||
|
return
|
||||||
|
case 'role_name_taken':
|
||||||
|
nameError.value = t('A role named "{0}" already exists. Choose a different name.', [
|
||||||
|
form.value.name.trim(),
|
||||||
|
])
|
||||||
|
return
|
||||||
|
case 'built_in_name':
|
||||||
|
nameError.value = t('Built-in roles keep their name.')
|
||||||
|
return
|
||||||
|
case 'missing_permission':
|
||||||
|
lowered.value = true
|
||||||
|
return
|
||||||
|
case 'role_locked':
|
||||||
|
serverLocked.value = true
|
||||||
|
showRefusal(error)
|
||||||
|
return
|
||||||
|
case 'own_role':
|
||||||
|
serverOwnRole.value = true
|
||||||
|
showRefusal(error)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
showRefusal(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!canSave.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nameError.value = ''
|
||||||
|
refusal.value = null
|
||||||
|
if (nameMissing.value) {
|
||||||
|
nameError.value = t('Give the role a name.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
name: form.value.name.trim(),
|
||||||
|
description: form.value.description,
|
||||||
|
grants: grantsToPayload(form.value.grants, ownerOnlyKeys.value),
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (isNew.value) {
|
||||||
|
const created = await api.createRole(payload)
|
||||||
|
adopt(created)
|
||||||
|
loadedKey = String(created.id)
|
||||||
|
toastSuccess(
|
||||||
|
`role-created-${created.id}`,
|
||||||
|
t('Role created'),
|
||||||
|
t('{0} is ready. Assign it from Users.', [created.name]),
|
||||||
|
)
|
||||||
|
await router.replace(`/settings/roles/${created.id}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const updated = await api.updateRole(role.value!.id, payload, role.value!.version)
|
||||||
|
adopt(updated)
|
||||||
|
toastSuccess(`role-saved-${updated.id}`, t('Role saved'), memberCountCopy(updated))
|
||||||
|
} catch (error) {
|
||||||
|
handleSaveError(error)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toastResetFailure() {
|
||||||
|
toast.add(() => ({
|
||||||
|
id: 'role-reset-failed',
|
||||||
|
color: 'error',
|
||||||
|
icon: 'i-lucide-circle-x',
|
||||||
|
title: t("Couldn't reset the role. Try again in a moment."),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetRole(): Promise<boolean> {
|
||||||
|
const target = role.value
|
||||||
|
if (!target) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const restored = await api.resetRole(target.id, target.version)
|
||||||
|
adopt(restored)
|
||||||
|
toastSuccess(
|
||||||
|
`role-reset-${restored.id}`,
|
||||||
|
t('Role reset'),
|
||||||
|
t('{0} is back to its defaults.', [restored.name]),
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof RoleConflictError) {
|
||||||
|
conflict.value = conflictFrom(error.latest)
|
||||||
|
} else if (error instanceof ApiError && error.code === 'missing_permission') {
|
||||||
|
lowered.value = true
|
||||||
|
} else if (error instanceof ApiError) {
|
||||||
|
showRefusal(error)
|
||||||
|
} else {
|
||||||
|
toastResetFailure()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleReview() {
|
||||||
|
reviewing.value = !reviewing.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicate() {
|
||||||
|
if (role.value) {
|
||||||
|
void router.push({
|
||||||
|
path: '/settings/roles/new',
|
||||||
|
query: { from: String(role.value.id) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isNew,
|
||||||
|
loadState,
|
||||||
|
role,
|
||||||
|
form,
|
||||||
|
initial,
|
||||||
|
catalogue,
|
||||||
|
saving,
|
||||||
|
nameError,
|
||||||
|
conflict,
|
||||||
|
lowered,
|
||||||
|
refusal,
|
||||||
|
reviewing,
|
||||||
|
readOnlyReason,
|
||||||
|
readOnly,
|
||||||
|
unsaved,
|
||||||
|
problems,
|
||||||
|
nameMissing,
|
||||||
|
canSave,
|
||||||
|
barVisible,
|
||||||
|
preset,
|
||||||
|
canReset,
|
||||||
|
gridTypes,
|
||||||
|
preview,
|
||||||
|
differenceRows,
|
||||||
|
load,
|
||||||
|
changeCell,
|
||||||
|
changeGeneral,
|
||||||
|
choosePreset,
|
||||||
|
resetType,
|
||||||
|
setName,
|
||||||
|
setDescription,
|
||||||
|
discard,
|
||||||
|
save,
|
||||||
|
reloadRole,
|
||||||
|
reloadPage,
|
||||||
|
resetRole,
|
||||||
|
toggleReview,
|
||||||
|
duplicate,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useApi, ApiError } from '@/composables/useApi'
|
||||||
|
import type { PermissionGroupItem, RoleItem, RolePayload } from '@/lib/roles'
|
||||||
|
|
||||||
|
const JSON_HEADERS = { 'Content-Type': 'application/json' }
|
||||||
|
const PASSTHROUGH = { onMissingPermission: 'passthrough' } as const
|
||||||
|
|
||||||
|
export class RoleConflictError extends Error {
|
||||||
|
latest: RoleItem | null
|
||||||
|
|
||||||
|
constructor(latest: RoleItem | null) {
|
||||||
|
super('Role changed while editing')
|
||||||
|
this.latest = latest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRoles() {
|
||||||
|
const { request } = useApi()
|
||||||
|
|
||||||
|
function listRoles(): Promise<RoleItem[]> {
|
||||||
|
return request<RoleItem[]>('/v1/role/', PASSTHROUGH)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRole(id: number): Promise<RoleItem> {
|
||||||
|
return request<RoleItem>(`/v1/role/${id}`, PASSTHROUGH)
|
||||||
|
}
|
||||||
|
|
||||||
|
function listPermissions(): Promise<PermissionGroupItem[]> {
|
||||||
|
return request<{ groups: PermissionGroupItem[] }>('/v1/permission/', PASSTHROUGH).then(
|
||||||
|
(body) => body.groups,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRole(payload: RolePayload): Promise<RoleItem> {
|
||||||
|
return request<RoleItem>('/v1/role/', {
|
||||||
|
...PASSTHROUGH,
|
||||||
|
method: 'POST',
|
||||||
|
headers: JSON_HEADERS,
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rereadAfterConflict<T>(id: number, send: () => Promise<T>): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await send()
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError && error.code === 'role_conflict') {
|
||||||
|
const latest = await getRole(id).catch(() => null)
|
||||||
|
throw new RoleConflictError(latest)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRole(id: number, payload: RolePayload, version: number): Promise<RoleItem> {
|
||||||
|
return rereadAfterConflict(id, () =>
|
||||||
|
request<RoleItem>(`/v1/role/${id}`, {
|
||||||
|
...PASSTHROUGH,
|
||||||
|
method: 'PUT',
|
||||||
|
headers: JSON_HEADERS,
|
||||||
|
body: JSON.stringify({ ...payload, version }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetRole(id: number, version: number): Promise<RoleItem> {
|
||||||
|
return rereadAfterConflict(id, () =>
|
||||||
|
request<RoleItem>(`/v1/role/${id}/reset`, {
|
||||||
|
...PASSTHROUGH,
|
||||||
|
method: 'POST',
|
||||||
|
headers: JSON_HEADERS,
|
||||||
|
body: JSON.stringify({ version }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRole(id: number): Promise<void> {
|
||||||
|
return request<void>(`/v1/role/${id}`, { ...PASSTHROUGH, method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function reassignRole(id: number, targetRoleId: number): Promise<{ moved: number }> {
|
||||||
|
return request<{ moved: number }>(`/v1/role/${id}/reassign`, {
|
||||||
|
...PASSTHROUGH,
|
||||||
|
method: 'POST',
|
||||||
|
headers: JSON_HEADERS,
|
||||||
|
body: JSON.stringify({ target_role_id: targetRoleId }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
listRoles,
|
||||||
|
getRole,
|
||||||
|
listPermissions,
|
||||||
|
createRole,
|
||||||
|
updateRole,
|
||||||
|
resetRole,
|
||||||
|
deleteRole,
|
||||||
|
reassignRole,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2917,6 +2917,226 @@ export const messages = {
|
|||||||
'Save draft': 'Enregistrer le brouillon',
|
'Save draft': 'Enregistrer le brouillon',
|
||||||
'The id of the role to give. Absent or 0 means the Writer role. The Owner role is refused.':
|
'The id of the role to give. Absent or 0 means the Writer role. The Owner role is refused.':
|
||||||
'L’identifiant du rôle à donner. Absent ou 0, c’est le rôle Rédacteur. Le rôle Propriétaire est refusé.',
|
'L’identifiant du rôle à donner. Absent ou 0, c’est le rôle Rédacteur. Le rôle Propriétaire est refusé.',
|
||||||
|
Access: 'Accès',
|
||||||
|
Roles: 'Rôles',
|
||||||
|
"Built-in roles can't be deleted.": 'Les rôles intégrés ne peuvent pas être supprimés.',
|
||||||
|
'{0} members have this role. Move them to another role first. Then {1} can be deleted.':
|
||||||
|
'{0} membres ont ce rôle. Déplacez-les d’abord vers un autre rôle. Ensuite, {1} pourra être supprimé.',
|
||||||
|
"No one has this role. This can't be undone.":
|
||||||
|
'Personne n’a ce rôle. Cette action est irréversible.',
|
||||||
|
'Duplicate role': 'Dupliquer le rôle',
|
||||||
|
'Move {0} members and delete role': 'Déplacer {0} membres et supprimer le rôle',
|
||||||
|
'Delete role': 'Supprimer le rôle',
|
||||||
|
'Moving and deleting…': 'Déplacement et suppression…',
|
||||||
|
"Couldn't delete the role. Try again in a moment.":
|
||||||
|
'Impossible de supprimer le rôle. Réessayez dans un instant.',
|
||||||
|
'Move members to': 'Déplacer les membres vers',
|
||||||
|
'They keep their API keys. Keys follow the new role.':
|
||||||
|
'Ils gardent leurs clés d’API. Les clés suivent le nouveau rôle.',
|
||||||
|
'{0} is built in': '{0} est intégré',
|
||||||
|
'Duplicate it to make a custom role you can change or delete.':
|
||||||
|
'Dupliquez-le pour créer un rôle personnalisé que vous pouvez modifier ou supprimer.',
|
||||||
|
"Deleting a role doesn't affect content.": 'Supprimer un rôle n’affecte pas le contenu.',
|
||||||
|
Read: 'Lire',
|
||||||
|
Create: 'Créer',
|
||||||
|
Own: 'Les siennes',
|
||||||
|
'{0}, {1}, set to {2}.': '{0}, {1}, réglé sur {2}.',
|
||||||
|
'Content permissions': 'Permissions de contenu',
|
||||||
|
'Content type': 'Type de contenu',
|
||||||
|
'Sets every row': 'Règle toutes les lignes',
|
||||||
|
'Schema locked': 'Schéma verrouillé',
|
||||||
|
'{0}, set to {1}.': '{0}, réglé sur {1}.',
|
||||||
|
'{0} of {1} enabled': '{0} sur {1} activées',
|
||||||
|
'Only the owner can grant this.': 'Seul le propriétaire peut accorder cette permission.',
|
||||||
|
'Read only': 'Lecture seule',
|
||||||
|
'Write own': 'Écrire les siens',
|
||||||
|
'Full content': 'Contenu complet',
|
||||||
|
'Quick presets': 'Préréglages rapides',
|
||||||
|
Added: 'Ajouté',
|
||||||
|
Removed: 'Retiré',
|
||||||
|
'The Owner role is locked': 'Le rôle Propriétaire est verrouillé',
|
||||||
|
'It always has every permission. To hand it to someone else, transfer ownership from Users.':
|
||||||
|
'Il a toujours toutes les permissions. Pour le confier à quelqu’un d’autre, transférez la propriété depuis Utilisateurs.',
|
||||||
|
'Go to Users': 'Aller aux utilisateurs',
|
||||||
|
'Only the owner can edit built-in roles':
|
||||||
|
'Seul le propriétaire peut modifier les rôles intégrés',
|
||||||
|
'You can read what {0} allows. To change it, duplicate it into a custom role.':
|
||||||
|
'Vous pouvez lire ce que {0} autorise. Pour le modifier, dupliquez-le en rôle personnalisé.',
|
||||||
|
'You can see roles but not change them': 'Vous pouvez voir les rôles sans les modifier',
|
||||||
|
'Ask an owner or admin if a role needs to change.':
|
||||||
|
'Demandez à un propriétaire ou à un administrateur si un rôle doit changer.',
|
||||||
|
"You can't change the permissions of your own role. Ask another owner or admin.":
|
||||||
|
'Vous ne pouvez pas modifier les permissions de votre propre rôle. Demandez à un autre propriétaire ou administrateur.',
|
||||||
|
'Your access changed': 'Votre accès a changé',
|
||||||
|
"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.":
|
||||||
|
'Un propriétaire a modifié votre rôle pendant que cette page était ouverte. Vous pouvez toujours voir les rôles, mais pas les modifier. Vos modifications n’ont pas été enregistrées.',
|
||||||
|
'This role has 1 member': 'Ce rôle a 1 membre',
|
||||||
|
'This role has {0} members': 'Ce rôle a {0} membres',
|
||||||
|
'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.':
|
||||||
|
'Les changements s’appliquent à tous à l’enregistrement, ainsi qu’aux clés d’API qu’ils ont créées. Les rôles intégrés peuvent être restaurés avec Réinitialiser.',
|
||||||
|
'Reload role': 'Recharger le rôle',
|
||||||
|
'Review differences': 'Voir les différences',
|
||||||
|
"Your role can't assign a role with more power than yours.":
|
||||||
|
'Votre rôle ne peut pas attribuer un rôle plus puissant que le vôtre.',
|
||||||
|
'Choose a role': 'Choisir un rôle',
|
||||||
|
'What this role can do': 'Ce que ce rôle peut faire',
|
||||||
|
'A summary of what this role allows.': 'Un résumé de ce que ce rôle autorise.',
|
||||||
|
'Updates as you edit.': 'Se met à jour pendant vos modifications.',
|
||||||
|
'Everything else': 'Tout le reste',
|
||||||
|
'1 unsaved change. Save is off until you reload':
|
||||||
|
'1 modification non enregistrée. L’enregistrement est désactivé jusqu’au rechargement',
|
||||||
|
'{0} unsaved changes. Save is off until you reload':
|
||||||
|
'{0} modifications non enregistrées. L’enregistrement est désactivé jusqu’au rechargement',
|
||||||
|
'Name your role to save it': 'Nommez votre rôle pour l’enregistrer',
|
||||||
|
'1 unsaved change': '1 modification non enregistrée',
|
||||||
|
'{0} unsaved changes': '{0} modifications non enregistrées',
|
||||||
|
Members: 'Membres',
|
||||||
|
Locked: 'Verrouillé',
|
||||||
|
'{0} members': '{0} membres',
|
||||||
|
'This role changed while you were editing': 'Ce rôle a changé pendant que vous le modifiiez',
|
||||||
|
'{0} saved changes to {1} at {2}. Reload to see them, or review how they differ from your edits.':
|
||||||
|
'{0} a enregistré des modifications sur {1} à {2}. Rechargez pour les voir, ou comparez-les à vos modifications.',
|
||||||
|
Someone: 'Quelqu’un',
|
||||||
|
"Couldn't save the role": 'Impossible d’enregistrer le rôle',
|
||||||
|
'{0} is updated for 1 member.': '{0} est mis à jour pour 1 membre.',
|
||||||
|
'{0} is updated for {1} members.': '{0} est mis à jour pour {1} membres.',
|
||||||
|
'Give the role a name.': 'Donnez un nom au rôle.',
|
||||||
|
'A role named "{0}" already exists. Choose a different name.':
|
||||||
|
'Un rôle nommé « {0} » existe déjà. Choisissez un autre nom.',
|
||||||
|
'Role created': 'Rôle créé',
|
||||||
|
'{0} is ready. Assign it from Users.': '{0} est prêt. Attribuez-le depuis Utilisateurs.',
|
||||||
|
'Role saved': 'Rôle enregistré',
|
||||||
|
'Reset {0} to default?': 'Réinitialiser {0} ?',
|
||||||
|
'Its permissions go back to what RenewCMS ships with.':
|
||||||
|
'Ses permissions reviennent à celles fournies par RenewCMS.',
|
||||||
|
'Reset to default': 'Réinitialiser',
|
||||||
|
'Resetting…': 'Réinitialisation…',
|
||||||
|
'Role reset': 'Rôle réinitialisé',
|
||||||
|
'{0} is back to its defaults.': '{0} est revenu à ses valeurs par défaut.',
|
||||||
|
"Couldn't reset the role. Try again in a moment.":
|
||||||
|
'Impossible de réinitialiser le rôle. Réessayez dans un instant.',
|
||||||
|
read: 'lire',
|
||||||
|
update: 'modifier',
|
||||||
|
delete: 'supprimer',
|
||||||
|
publish: 'publier',
|
||||||
|
"Your role can {0} only entries you created, so All isn't available.":
|
||||||
|
'Votre rôle peut {0} uniquement les entrées que vous avez créées, donc Toutes n’est pas disponible.',
|
||||||
|
'The Owner role can’t be deleted.': 'Le rôle Propriétaire ne peut pas être supprimé.',
|
||||||
|
"Built-in roles can't be deleted. Duplicate it to make a custom role.":
|
||||||
|
'Les rôles intégrés ne peuvent pas être supprimés. Dupliquez-le pour créer un rôle personnalisé.',
|
||||||
|
'You can see roles, but only people who manage roles can create or change them.':
|
||||||
|
'Vous pouvez voir les rôles, mais seules les personnes qui gèrent les rôles peuvent les créer ou les modifier.',
|
||||||
|
'only their own': 'uniquement les siens',
|
||||||
|
'see content types and fields': 'voir les types de contenu et leurs champs',
|
||||||
|
'create and edit content types': 'créer et modifier des types de contenu',
|
||||||
|
'delete content types': 'supprimer des types de contenu',
|
||||||
|
'browse the media library': 'parcourir la médiathèque',
|
||||||
|
'upload files': 'téléverser des fichiers',
|
||||||
|
'delete files': 'supprimer des fichiers',
|
||||||
|
'see authors': 'voir les auteurs',
|
||||||
|
'create authors': 'créer des auteurs',
|
||||||
|
'edit authors': 'modifier des auteurs',
|
||||||
|
'delete authors': 'supprimer des auteurs',
|
||||||
|
'see activity': 'voir l’activité',
|
||||||
|
'export activity': 'exporter l’activité',
|
||||||
|
'see users': 'voir les utilisateurs',
|
||||||
|
'invite users': 'inviter des utilisateurs',
|
||||||
|
"change a user's role": 'changer le rôle d’un utilisateur',
|
||||||
|
'remove users': 'retirer des utilisateurs',
|
||||||
|
'see settings': 'voir les paramètres',
|
||||||
|
'edit general settings': 'modifier les paramètres généraux',
|
||||||
|
'manage sites': 'gérer les sites',
|
||||||
|
'delete a site': 'supprimer un site',
|
||||||
|
'see API keys': 'voir les clés d’API',
|
||||||
|
'create API keys': 'créer des clés d’API',
|
||||||
|
'revoke API keys': 'révoquer des clés d’API',
|
||||||
|
'see roles': 'voir les rôles',
|
||||||
|
'manage roles': 'gérer les rôles',
|
||||||
|
'edit roles with owner-only power': 'modifier les rôles à pouvoir réservé au propriétaire',
|
||||||
|
'apply software updates': 'appliquer les mises à jour du logiciel',
|
||||||
|
'edit activity retention': 'modifier la conservation de l’activité',
|
||||||
|
'transfer ownership': 'transférer la propriété',
|
||||||
|
'read any entry': 'lire n’importe quelle entrée',
|
||||||
|
'read only their own entries': 'lire uniquement leurs propres entrées',
|
||||||
|
'create entries': 'créer des entrées',
|
||||||
|
'edit any entry': 'modifier n’importe quelle entrée',
|
||||||
|
'edit only their own': 'modifier uniquement les leurs',
|
||||||
|
'delete any entry': 'supprimer n’importe quelle entrée',
|
||||||
|
'delete only their own entries': 'supprimer uniquement leurs propres entrées',
|
||||||
|
'publish any entry': 'publier n’importe quelle entrée',
|
||||||
|
'publish only their own entries': 'publier uniquement leurs propres entrées',
|
||||||
|
create: 'créer',
|
||||||
|
edit: 'modifier',
|
||||||
|
'No access to entries.': 'Aucun accès aux entrées.',
|
||||||
|
'Can {0}.': 'Peut {0}.',
|
||||||
|
"Can't {0}.": 'Ne peut pas {0}.',
|
||||||
|
'Applies to 1 member, and to the API keys they created.':
|
||||||
|
'S’applique à 1 membre, et aux clés d’API qu’il a créées.',
|
||||||
|
'Applies to {0} members, and to the API keys they created.':
|
||||||
|
'S’applique à {0} membres, et aux clés d’API qu’ils ont créées.',
|
||||||
|
'No access to {0}.': 'Aucun accès à {0}.',
|
||||||
|
'New role': 'Nouveau rôle',
|
||||||
|
"Couldn't load this role": 'Impossible de charger ce rôle',
|
||||||
|
'This role no longer exists': 'Ce rôle n’existe plus',
|
||||||
|
'It may have been deleted.': 'Il a peut-être été supprimé.',
|
||||||
|
'Back to Roles': 'Retour aux rôles',
|
||||||
|
'1 member': '1 membre',
|
||||||
|
'For example, Contributor': 'Par exemple, Contributeur',
|
||||||
|
'Description (optional)': 'Description (facultatif)',
|
||||||
|
'What this role is for': 'À quoi sert ce rôle',
|
||||||
|
'What this role can do with entries of each content type. Own means entries the person created.':
|
||||||
|
'Ce que ce rôle peut faire avec les entrées de chaque type de contenu. « Les siennes » désigne les entrées créées par la personne.',
|
||||||
|
'General permissions': 'Permissions générales',
|
||||||
|
'Everything outside entries.': 'Tout ce qui n’est pas une entrée.',
|
||||||
|
'Collapse all': 'Tout réduire',
|
||||||
|
'Expand all': 'Tout développer',
|
||||||
|
'Role deleted': 'Rôle supprimé',
|
||||||
|
'{0} is gone.': '{0} a été supprimé.',
|
||||||
|
'Who can do what on this site. Each user has one role.':
|
||||||
|
'Qui peut faire quoi sur ce site. Chaque utilisateur a un seul rôle.',
|
||||||
|
"Couldn't load roles": 'Impossible de charger les rôles',
|
||||||
|
'GET /v1/role failed. Check your connection and try again.':
|
||||||
|
'GET /v1/role a échoué. Vérifiez votre connexion et réessayez.',
|
||||||
|
'No custom roles yet': 'Aucun rôle personnalisé pour l’instant',
|
||||||
|
'The three built-in roles cover most sites. Create a role when someone needs something in between.':
|
||||||
|
'Les trois rôles intégrés couvrent la plupart des sites. Créez un rôle quand quelqu’un a besoin de quelque chose d’intermédiaire.',
|
||||||
|
'See entries of this type.': 'Voir les entrées de ce type.',
|
||||||
|
'Add new entries.': 'Ajouter des entrées.',
|
||||||
|
'Change existing entries.': 'Modifier les entrées existantes.',
|
||||||
|
'Remove entries.': 'Supprimer des entrées.',
|
||||||
|
'Make entries public.': 'Rendre des entrées publiques.',
|
||||||
|
'See content types and their fields.': 'Voir les types de contenu et leurs champs.',
|
||||||
|
'Add content types and change their fields.':
|
||||||
|
'Ajouter des types de contenu et modifier leurs champs.',
|
||||||
|
'Remove content types.': 'Supprimer des types de contenu.',
|
||||||
|
'Browse the images and files.': 'Parcourir les images et les fichiers.',
|
||||||
|
'Add images and files.': 'Ajouter des images et des fichiers.',
|
||||||
|
'Remove images and files.': 'Supprimer des images et des fichiers.',
|
||||||
|
'See the list of authors.': 'Voir la liste des auteurs.',
|
||||||
|
'Add authors.': 'Ajouter des auteurs.',
|
||||||
|
'Change author profiles.': 'Modifier les profils des auteurs.',
|
||||||
|
'Remove authors.': 'Supprimer des auteurs.',
|
||||||
|
'Read the log of who did what.': 'Lire le journal de qui a fait quoi.',
|
||||||
|
'Download the log as a CSV file.': 'Télécharger le journal en fichier CSV.',
|
||||||
|
'See who has an account.': 'Voir qui a un compte.',
|
||||||
|
'Send invitations by email.': 'Envoyer des invitations par e-mail.',
|
||||||
|
'Give someone another role.': 'Donner un autre rôle à quelqu’un.',
|
||||||
|
'Take away someone’s access.': 'Retirer l’accès de quelqu’un.',
|
||||||
|
'Read the site settings.': 'Lire les paramètres du site.',
|
||||||
|
'Change the site settings.': 'Modifier les paramètres du site.',
|
||||||
|
'Add, edit and remove sites.': 'Ajouter, modifier et supprimer des sites.',
|
||||||
|
'Remove a site and its content.': 'Supprimer un site et son contenu.',
|
||||||
|
'See the API keys.': 'Voir les clés d’API.',
|
||||||
|
'Make new API keys.': 'Créer de nouvelles clés d’API.',
|
||||||
|
'Stop an API key from working.': 'Empêcher une clé d’API de fonctionner.',
|
||||||
|
'See roles and what they allow.': 'Voir les rôles et ce qu’ils autorisent.',
|
||||||
|
'Create, edit, duplicate and delete roles.':
|
||||||
|
'Créer, modifier, dupliquer et supprimer des rôles.',
|
||||||
|
'Change a role that carries owner-only abilities.':
|
||||||
|
'Modifier un rôle qui porte des pouvoirs réservés au propriétaire.',
|
||||||
|
'Install a new version of RenewCMS.': 'Installer une nouvelle version de RenewCMS.',
|
||||||
|
'How long the activity log is kept.': 'Durée de conservation du journal d’activité.',
|
||||||
|
'Make another member the owner.': 'Faire d’un autre membre le propriétaire.',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type MessageKey = keyof typeof messages
|
export type MessageKey = keyof typeof messages
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { messages } from './messages'
|
||||||
|
import { setLocale, t } from './index'
|
||||||
|
|
||||||
|
const ROLE_KEYS = [
|
||||||
|
'Access',
|
||||||
|
'Roles',
|
||||||
|
'New role',
|
||||||
|
'Who can do what on this site. Each user has one role.',
|
||||||
|
'No custom roles yet',
|
||||||
|
"Couldn't load roles",
|
||||||
|
'You can see roles but not change them',
|
||||||
|
'Content permissions',
|
||||||
|
'Quick presets',
|
||||||
|
'Read only',
|
||||||
|
'Write own',
|
||||||
|
'Full content',
|
||||||
|
'General permissions',
|
||||||
|
'Expand all',
|
||||||
|
'{0} unsaved changes',
|
||||||
|
'Give the role a name.',
|
||||||
|
'A role named "{0}" already exists. Choose a different name.',
|
||||||
|
'This role changed while you were editing',
|
||||||
|
'Your access changed',
|
||||||
|
'The Owner role is locked',
|
||||||
|
'Only the owner can edit built-in roles',
|
||||||
|
'What this role can do',
|
||||||
|
'Role created',
|
||||||
|
'{0} is ready. Assign it from Users.',
|
||||||
|
'Role saved',
|
||||||
|
'{0} members have this role. Move them to another role first. Then {1} can be deleted.',
|
||||||
|
'Move {0} members and delete role',
|
||||||
|
"No one has this role. This can't be undone.",
|
||||||
|
'Can {0}.',
|
||||||
|
"Can't {0}.",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const PLACEHOLDER = /\{\d+\}/g
|
||||||
|
|
||||||
|
afterEach(() => setLocale('en'))
|
||||||
|
|
||||||
|
describe('Roles screens copy', () => {
|
||||||
|
it.each(ROLE_KEYS)('"%s" has a French entry', (key) => {
|
||||||
|
expect((messages as Record<string, string>)[key]).toMatch(/\S/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the same placeholders in French as in English for every entry', () => {
|
||||||
|
const mismatches = Object.entries(messages).filter(([english, french]) => {
|
||||||
|
const expected = (english.match(PLACEHOLDER) ?? []).sort().join()
|
||||||
|
const actual = (french.match(PLACEHOLDER) ?? []).sort().join()
|
||||||
|
return expected !== actual
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mismatches.map(([english]) => english)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders French with its values', () => {
|
||||||
|
setLocale('fr')
|
||||||
|
|
||||||
|
expect(t('{0} unsaved changes', [3])).toBe('3 modifications non enregistrées')
|
||||||
|
expect(t('Roles')).toBe('Rôles')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { effectiveScope, formatGrantKey, parseGrantKey } from '@/lib/permissions'
|
||||||
|
import { setScope, scopeAt, isCustomRow, resetRow } from '@/lib/roleForm'
|
||||||
|
import { canCover } from '@/lib/grantLocks'
|
||||||
|
import { baseKey } from '@/lib/permissionNames'
|
||||||
|
|
||||||
|
describe('parseGrantKey and formatGrantKey', () => {
|
||||||
|
it('parses a bare key without a type', () => {
|
||||||
|
expect(parseGrantKey('content.read')).toEqual({ base: 'content.read' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses a typed key', () => {
|
||||||
|
expect(parseGrantKey('content.read:events')).toEqual({
|
||||||
|
base: 'content.read',
|
||||||
|
typeKey: 'events',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('splits on the first colon only so a type key may contain a colon', () => {
|
||||||
|
expect(parseGrantKey('content.update:ns:case-studies')).toEqual({
|
||||||
|
base: 'content.update',
|
||||||
|
typeKey: 'ns:case-studies',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats and parses back to the same parts', () => {
|
||||||
|
const key = formatGrantKey('content.update', 'ns:case-studies')
|
||||||
|
|
||||||
|
expect(key).toBe('content.update:ns:case-studies')
|
||||||
|
expect(parseGrantKey(key)).toEqual({ base: 'content.update', typeKey: 'ns:case-studies' })
|
||||||
|
expect(formatGrantKey('content.update')).toBe('content.update')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a colon type key working through the form, locks and names', () => {
|
||||||
|
const grants = setScope({}, 'content.update', 'own', 'ns:a')
|
||||||
|
|
||||||
|
expect(grants).toEqual({ 'content.update:ns:a': 'own' })
|
||||||
|
expect(scopeAt(grants, 'content.update', 'ns:a')).toBe('own')
|
||||||
|
expect(
|
||||||
|
effectiveScope([{ key: 'content.update:ns:a', scope: 'own' }], 'content.update', {
|
||||||
|
typeKey: 'ns:a',
|
||||||
|
}),
|
||||||
|
).toBe('own')
|
||||||
|
expect(isCustomRow(grants, 'ns:a')).toBe(true)
|
||||||
|
expect(resetRow(grants, 'ns:a')).toEqual({})
|
||||||
|
expect(baseKey('content.update:ns:a')).toBe('content.update')
|
||||||
|
const held: string[] = []
|
||||||
|
canCover({ grants: [{ key: 'content.update:ns:a', scope: 'own' }] }, (key, options) => {
|
||||||
|
held.push(`${key}|${options?.typeKey}`)
|
||||||
|
return 'all'
|
||||||
|
})
|
||||||
|
expect(held).toEqual(['content.update|ns:a'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { canCover, lockReason, lockedScopes } from '@/lib/grantLocks'
|
||||||
|
import { effectiveScope, type Grant } from '@/lib/permissions'
|
||||||
|
import { grantsFrom } from '@/test/access'
|
||||||
|
import type { RoleItem } from '@/lib/roles'
|
||||||
|
|
||||||
|
function scopeFor(spec: string) {
|
||||||
|
const grants: Grant[] = grantsFrom(spec)
|
||||||
|
return (key: string, options?: { typeKey?: string }) => effectiveScope(grants, key, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
function role(spec: string): Pick<RoleItem, 'grants'> {
|
||||||
|
return { grants: grantsFrom(spec) }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('lockedScopes', () => {
|
||||||
|
it('locks nothing when the caller holds All', () => {
|
||||||
|
expect(lockedScopes('all')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('locks All when the caller holds only Own', () => {
|
||||||
|
expect(lockedScopes('own')).toEqual(['all'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps None available and locks Own and All when the caller holds nothing', () => {
|
||||||
|
expect(lockedScopes('none')).toEqual(['own', 'all'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('lockReason', () => {
|
||||||
|
it('names the action when the caller holds Own', () => {
|
||||||
|
expect(lockReason('content.publish', 'own')).toBe(
|
||||||
|
"You can only grant what your role has. Your role can publish only entries you created, so All isn't available.",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the generic reason for non-content keys', () => {
|
||||||
|
expect(lockReason('media.delete', 'own')).toBe('You can only grant what your role has.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has no reason when the caller holds All', () => {
|
||||||
|
expect(lockReason('content.publish', 'all')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a typed none in the session locks the matching cell through the scope lookup', () => {
|
||||||
|
const scopeOf = scopeFor('content.update=all, content.update:article=none')
|
||||||
|
|
||||||
|
expect(lockedScopes(scopeOf('content.update', { typeKey: 'article' }))).toEqual([
|
||||||
|
'own',
|
||||||
|
'all',
|
||||||
|
])
|
||||||
|
expect(lockedScopes(scopeOf('content.update', { typeKey: 'events' }))).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('canCover', () => {
|
||||||
|
it('covers a role whose every grant is within the caller scope', () => {
|
||||||
|
expect(
|
||||||
|
canCover(
|
||||||
|
role('content.read=all, content.update=own'),
|
||||||
|
scopeFor('content.read, content.update=all'),
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a role that grants more than the caller holds', () => {
|
||||||
|
expect(canCover(role('content.publish=all'), scopeFor('content.publish=own'))).toBe(false)
|
||||||
|
expect(canCover(role('content.read'), scopeFor('content.update'))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('checks typed grants against the caller typed scope', () => {
|
||||||
|
const scopeOf = scopeFor('content.update=all, content.update:article=own')
|
||||||
|
|
||||||
|
expect(canCover(role('content.update:article=all'), scopeOf)).toBe(false)
|
||||||
|
expect(canCover(role('content.update:events=all'), scopeOf)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
activePreset,
|
||||||
|
applyPreset,
|
||||||
|
differences,
|
||||||
|
emptyForm,
|
||||||
|
formFromRole,
|
||||||
|
grantsToPayload,
|
||||||
|
isCustomRow,
|
||||||
|
resetRow,
|
||||||
|
scopeAt,
|
||||||
|
setScope,
|
||||||
|
unsavedCount,
|
||||||
|
} from '@/lib/roleForm'
|
||||||
|
|
||||||
|
const CONTRIBUTOR = {
|
||||||
|
name: 'Contributor',
|
||||||
|
description: 'Creates content and edits only their own.',
|
||||||
|
grants: [
|
||||||
|
{ key: 'content.read', scope: 'all' as const },
|
||||||
|
{ key: 'content.create', scope: 'all' as const },
|
||||||
|
{ key: 'content.update', scope: 'own' as const },
|
||||||
|
{ key: 'content.update:case-studies', scope: 'all' as const },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_OWNER_ONLY = new Set<string>()
|
||||||
|
|
||||||
|
describe('formFromRole', () => {
|
||||||
|
it('fills name, description and the grants by key', () => {
|
||||||
|
const form = formFromRole(CONTRIBUTOR)
|
||||||
|
|
||||||
|
expect(form.name).toBe('Contributor')
|
||||||
|
expect(form.description).toBe('Creates content and edits only their own.')
|
||||||
|
expect(form.grants['content.update:case-studies']).toBe('all')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('scopeAt', () => {
|
||||||
|
it('reads the typed grant first, then the wildcard, then none', () => {
|
||||||
|
const { grants } = formFromRole(CONTRIBUTOR)
|
||||||
|
|
||||||
|
expect(scopeAt(grants, 'content.update', 'case-studies')).toBe('all')
|
||||||
|
expect(scopeAt(grants, 'content.update', 'events')).toBe('own')
|
||||||
|
expect(scopeAt(grants, 'content.delete', 'events')).toBe('none')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('grantsToPayload', () => {
|
||||||
|
it('sends wildcard grants and typed grants, omits none wildcards, keeps typed none', () => {
|
||||||
|
const grants = setScope(
|
||||||
|
setScope(
|
||||||
|
{ 'content.read': 'all', 'content.delete': 'none' },
|
||||||
|
'content.update',
|
||||||
|
'none',
|
||||||
|
'events',
|
||||||
|
),
|
||||||
|
'content.publish',
|
||||||
|
'own',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(grantsToPayload(grants, NO_OWNER_ONLY)).toEqual([
|
||||||
|
{ key: 'content.read', scope: 'all' },
|
||||||
|
{ key: 'content.update:events', scope: 'none' },
|
||||||
|
{ key: 'content.publish', scope: 'own' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never sends an owner-only key', () => {
|
||||||
|
const payload = grantsToPayload(
|
||||||
|
{ 'content.read': 'all', 'system.apply_updates': 'all' },
|
||||||
|
new Set(['system.apply_updates']),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(payload).toEqual([{ key: 'content.read', scope: 'all' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never puts an owner-only key in a custom-role payload', () => {
|
||||||
|
const ownerOnly = [
|
||||||
|
'settings.delete_site',
|
||||||
|
'roles.edit_owner_only',
|
||||||
|
'system.apply_updates',
|
||||||
|
'system.edit_retention',
|
||||||
|
'system.transfer_ownership',
|
||||||
|
]
|
||||||
|
const grants = Object.fromEntries([
|
||||||
|
['content.read', 'all'],
|
||||||
|
...ownerOnly.map((key) => [key, 'all']),
|
||||||
|
])
|
||||||
|
|
||||||
|
const payload = grantsToPayload(grants, new Set(ownerOnly))
|
||||||
|
|
||||||
|
expect(payload).toEqual([{ key: 'content.read', scope: 'all' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends nothing for a new role with nothing granted', () => {
|
||||||
|
expect(grantsToPayload(emptyForm().grants, NO_OWNER_ONLY)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('custom rows', () => {
|
||||||
|
it('marks a type with a typed grant as custom and Reset removes its typed grants', () => {
|
||||||
|
const { grants } = formFromRole(CONTRIBUTOR)
|
||||||
|
|
||||||
|
expect(isCustomRow(grants, 'case-studies')).toBe(true)
|
||||||
|
expect(isCustomRow(grants, 'events')).toBe(false)
|
||||||
|
|
||||||
|
const reset = resetRow(grants, 'case-studies')
|
||||||
|
|
||||||
|
expect(isCustomRow(reset, 'case-studies')).toBe(false)
|
||||||
|
expect(scopeAt(reset, 'content.update', 'case-studies')).toBe('own')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats a typed none as a custom row', () => {
|
||||||
|
expect(isCustomRow({ 'content.read:events': 'none' }, 'events')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setting the All content types row clears the typed grants of that action', () => {
|
||||||
|
const next = setScope({ 'content.update:events': 'all' }, 'content.update', 'own')
|
||||||
|
|
||||||
|
expect(next).toEqual({ 'content.update': 'own' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('presets', () => {
|
||||||
|
it.each([
|
||||||
|
['read_only', ['all', 'none', 'none', 'none', 'none']],
|
||||||
|
['write_own', ['all', 'all', 'own', 'none', 'none']],
|
||||||
|
['full_content', ['all', 'all', 'all', 'all', 'all']],
|
||||||
|
] as const)('%s sets every content row and is then the active preset', (preset, expected) => {
|
||||||
|
const grants = applyPreset({ 'content.update:events': 'none', 'media.view': 'all' }, preset)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
['read', 'create', 'update', 'delete', 'publish'].map((action) =>
|
||||||
|
scopeAt(grants, `content.${action}`, 'events'),
|
||||||
|
),
|
||||||
|
).toEqual(expected)
|
||||||
|
expect(grants['media.view']).toBe('all')
|
||||||
|
expect(activePreset(grants)).toBe(preset)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has no active preset once a type row is customised', () => {
|
||||||
|
const grants = setScope(applyPreset({}, 'write_own'), 'content.update', 'all', 'events')
|
||||||
|
|
||||||
|
expect(activePreset(grants)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('unsavedCount', () => {
|
||||||
|
it('counts changed entries (a cleared typed override included), one each for name and description', () => {
|
||||||
|
const initial = formFromRole(CONTRIBUTOR)
|
||||||
|
const current = {
|
||||||
|
...initial,
|
||||||
|
name: 'Contributor 2',
|
||||||
|
grants: setScope(
|
||||||
|
setScope(initial.grants, 'content.update', 'all'),
|
||||||
|
'content.delete',
|
||||||
|
'own',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(unsavedCount(initial, initial)).toBe(0)
|
||||||
|
expect(unsavedCount(current, initial)).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts a Write own preset on an empty form', () => {
|
||||||
|
const current = { ...emptyForm(), grants: applyPreset({}, 'write_own') }
|
||||||
|
|
||||||
|
expect(unsavedCount(current, emptyForm())).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Discard returns to zero', () => {
|
||||||
|
const initial = formFromRole(CONTRIBUTOR)
|
||||||
|
|
||||||
|
expect(unsavedCount({ ...initial, grants: { ...initial.grants } }, initial)).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('differences', () => {
|
||||||
|
it('lists keys that differ between two forms', () => {
|
||||||
|
const ours = formFromRole(CONTRIBUTOR)
|
||||||
|
const theirs = formFromRole({
|
||||||
|
...CONTRIBUTOR,
|
||||||
|
grants: [{ key: 'content.read', scope: 'all' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
differences(ours, theirs)
|
||||||
|
.map((entry) => entry.key)
|
||||||
|
.sort(),
|
||||||
|
).toEqual(['content.create', 'content.update', 'content.update:case-studies'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { applyPreset, setScope } from '@/lib/roleForm'
|
||||||
|
import { buildRolePreview } from '@/lib/rolePreview'
|
||||||
|
import { setLocale } from '@/i18n'
|
||||||
|
|
||||||
|
const TYPES = [
|
||||||
|
{ key: 'article', name: 'Articles' },
|
||||||
|
{ key: 'events', name: 'Events' },
|
||||||
|
{ key: 'case-studies', name: 'Case studies' },
|
||||||
|
]
|
||||||
|
|
||||||
|
afterEach(() => setLocale('en'))
|
||||||
|
|
||||||
|
describe('buildRolePreview content', () => {
|
||||||
|
it('reads the Contributor sentence from the Write own grants', () => {
|
||||||
|
const preview = buildRolePreview({
|
||||||
|
grants: applyPreset({}, 'write_own'),
|
||||||
|
types: TYPES,
|
||||||
|
members: 4,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(preview.content).toEqual([
|
||||||
|
{
|
||||||
|
id: 'all',
|
||||||
|
label: 'All content types',
|
||||||
|
sentence:
|
||||||
|
"Can read any entry, create entries and edit only their own. Can't delete and publish.",
|
||||||
|
changed: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says No access to entries when nothing is granted', () => {
|
||||||
|
const preview = buildRolePreview({ grants: {}, types: TYPES })
|
||||||
|
|
||||||
|
expect(preview.content[0]).toMatchObject({
|
||||||
|
label: 'All content types',
|
||||||
|
sentence: 'No access to entries.',
|
||||||
|
})
|
||||||
|
expect(preview.noAccess).toBe(
|
||||||
|
'No access to Schema, Media, Authors, Activity, Users and invites, Settings, Roles.',
|
||||||
|
)
|
||||||
|
expect(preview.everything).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes one line per group of types that share permissions', () => {
|
||||||
|
const grants = setScope(applyPreset({}, 'write_own'), 'content.update', 'all', 'events')
|
||||||
|
|
||||||
|
const preview = buildRolePreview({ grants, types: TYPES })
|
||||||
|
|
||||||
|
expect(preview.content.map((line) => line.label)).toEqual([
|
||||||
|
'Articles, Case studies',
|
||||||
|
'Events',
|
||||||
|
])
|
||||||
|
expect(preview.content[1]!.sentence).toContain('edit any entry')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildRolePreview everything else', () => {
|
||||||
|
it('reads the Schema, Media and Authors sentences and lists the areas without access', () => {
|
||||||
|
const preview = buildRolePreview({
|
||||||
|
grants: {
|
||||||
|
...applyPreset({}, 'write_own'),
|
||||||
|
'schema.view': 'all',
|
||||||
|
'media.view': 'all',
|
||||||
|
'media.upload': 'all',
|
||||||
|
'authors.view': 'all',
|
||||||
|
'authors.edit': 'own',
|
||||||
|
},
|
||||||
|
types: TYPES,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(preview.everything.map((line) => [line.label, line.sentence])).toEqual([
|
||||||
|
['Schema', 'Can see content types and fields.'],
|
||||||
|
['Media', 'Can browse the media library and upload files.'],
|
||||||
|
['Authors', 'Can see authors and edit authors, only their own.'],
|
||||||
|
])
|
||||||
|
expect(preview.noAccess).toBe('No access to Activity, Users and invites, Settings, Roles.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildRolePreview own scope without an own wording', () => {
|
||||||
|
it('never renders the all wording for an own grant on a phrase without an own variant', () => {
|
||||||
|
const preview = buildRolePreview({
|
||||||
|
grants: { 'users.view': 'own', 'users.invite': 'all', 'media.delete': 'own' },
|
||||||
|
types: TYPES,
|
||||||
|
})
|
||||||
|
|
||||||
|
const all = [...preview.everything.map((line) => line.sentence), preview.noAccess ?? '']
|
||||||
|
const text = all.join(' ')
|
||||||
|
expect(text).not.toContain('see users')
|
||||||
|
expect(text).toContain('invite users')
|
||||||
|
expect(preview.everything.find((line) => line.id === 'media')?.sentence).toContain(
|
||||||
|
'only their own',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildRolePreview Changed tagging and members', () => {
|
||||||
|
it('tags the line whose sentence differs from the initial grants', () => {
|
||||||
|
const initial = applyPreset({}, 'write_own')
|
||||||
|
const grants = setScope(initial, 'content.update', 'all')
|
||||||
|
|
||||||
|
const preview = buildRolePreview({ grants, types: TYPES, initial, members: 4 })
|
||||||
|
|
||||||
|
expect(preview.content[0]!.changed).toBe(true)
|
||||||
|
expect(preview.content[0]!.sentence).toContain('edit any entry')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tags nothing when the grants equal the initial ones', () => {
|
||||||
|
const initial = applyPreset({}, 'write_own')
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildRolePreview({ grants: initial, types: TYPES, initial }).content[0]!.changed,
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tags nothing without initial grants', () => {
|
||||||
|
expect(
|
||||||
|
buildRolePreview({ grants: applyPreset({}, 'write_own'), types: TYPES }).content[0]!
|
||||||
|
.changed,
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('states the member count, in the singular for one', () => {
|
||||||
|
expect(buildRolePreview({ grants: {}, types: TYPES, members: 4 }).members).toBe(
|
||||||
|
'Applies to 4 members, and to the API keys they created.',
|
||||||
|
)
|
||||||
|
expect(buildRolePreview({ grants: {}, types: TYPES, members: 1 }).members).toBe(
|
||||||
|
'Applies to 1 member, and to the API keys they created.',
|
||||||
|
)
|
||||||
|
expect(buildRolePreview({ grants: {}, types: TYPES, members: 0 }).members).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -6,6 +6,11 @@ export const KNOWN_ERROR_CODES = [
|
|||||||
'role_built_in',
|
'role_built_in',
|
||||||
'built_in_name',
|
'built_in_name',
|
||||||
'role_conflict',
|
'role_conflict',
|
||||||
|
'role_name_taken',
|
||||||
|
'role_has_members',
|
||||||
|
'invalid_role_name',
|
||||||
|
'invalid_reassign',
|
||||||
|
'role_not_found',
|
||||||
'user_role_changed',
|
'user_role_changed',
|
||||||
'session_required',
|
'session_required',
|
||||||
] as const
|
] as const
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { t } from '@/i18n'
|
||||||
|
import { parseGrantKey, type Scope } from '@/lib/permissions'
|
||||||
|
import type { RoleItem } from '@/lib/roles'
|
||||||
|
|
||||||
|
export type CallerScope = (key: string, options?: { typeKey?: string }) => Scope
|
||||||
|
|
||||||
|
const RANK: Record<Scope, number> = { none: 0, own: 1, all: 2 }
|
||||||
|
|
||||||
|
const CONTENT_VERBS: Record<string, () => string> = {
|
||||||
|
'content.read': () => t('read'),
|
||||||
|
'content.update': () => t('update'),
|
||||||
|
'content.delete': () => t('delete'),
|
||||||
|
'content.publish': () => t('publish'),
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lockedScopes(caller: Scope): Scope[] {
|
||||||
|
if (caller === 'all') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return caller === 'own' ? ['all'] : ['own', 'all']
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lockReason(key: string, caller: Scope): string | undefined {
|
||||||
|
if (caller === 'all') {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const base = t('You can only grant what your role has.')
|
||||||
|
const verb = CONTENT_VERBS[key]
|
||||||
|
if (caller === 'own' && verb !== undefined) {
|
||||||
|
return `${base} ${t("Your role can {0} only entries you created, so All isn't available.", [verb()])}`
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canCover(role: Pick<RoleItem, 'grants'>, scopeOf: CallerScope): boolean {
|
||||||
|
return role.grants.every((grant) => {
|
||||||
|
const { base, typeKey } = parseGrantKey(grant.key)
|
||||||
|
const held = typeKey === undefined ? scopeOf(base) : scopeOf(base, { typeKey })
|
||||||
|
return RANK[held] >= RANK[grant.scope]
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -49,6 +49,11 @@ export function refusalCopy(code: ApiErrorCode, permission?: string): RefusalCop
|
|||||||
case 'role_conflict':
|
case 'role_conflict':
|
||||||
case 'user_role_changed':
|
case 'user_role_changed':
|
||||||
return { title: t('This changed somewhere else. Reload and try again.') }
|
return { title: t('This changed somewhere else. Reload and try again.') }
|
||||||
|
case 'role_name_taken':
|
||||||
|
case 'role_has_members':
|
||||||
|
case 'invalid_role_name':
|
||||||
|
case 'invalid_reassign':
|
||||||
|
case 'role_not_found':
|
||||||
case 'session_required':
|
case 'session_required':
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
import { parseGrantKey } from '@/lib/permissions'
|
||||||
import type { MessageKey } from '@/i18n/messages'
|
import type { MessageKey } from '@/i18n/messages'
|
||||||
|
|
||||||
const NAMES = {
|
const NAMES = {
|
||||||
@@ -41,10 +42,52 @@ const NAMES = {
|
|||||||
export const PERMISSION_KEYS = Object.keys(NAMES)
|
export const PERMISSION_KEYS = Object.keys(NAMES)
|
||||||
|
|
||||||
export function baseKey(key: string) {
|
export function baseKey(key: string) {
|
||||||
return key.split(':')[0]!
|
return parseGrantKey(key).base
|
||||||
}
|
}
|
||||||
|
|
||||||
export function permissionName(key: string): string | undefined {
|
export function permissionName(key: string): string | undefined {
|
||||||
const name = NAMES[baseKey(key) as keyof typeof NAMES]
|
const name = NAMES[baseKey(key) as keyof typeof NAMES]
|
||||||
return name === undefined ? undefined : t(name)
|
return name === undefined ? undefined : t(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DESCRIPTIONS = {
|
||||||
|
'content.read': 'See entries of this type.',
|
||||||
|
'content.create': 'Add new entries.',
|
||||||
|
'content.update': 'Change existing entries.',
|
||||||
|
'content.delete': 'Remove entries.',
|
||||||
|
'content.publish': 'Make entries public.',
|
||||||
|
'schema.view': 'See content types and their fields.',
|
||||||
|
'schema.edit': 'Add content types and change their fields.',
|
||||||
|
'schema.delete': 'Remove content types.',
|
||||||
|
'media.view': 'Browse the images and files.',
|
||||||
|
'media.upload': 'Add images and files.',
|
||||||
|
'media.delete': 'Remove images and files.',
|
||||||
|
'authors.view': 'See the list of authors.',
|
||||||
|
'authors.create': 'Add authors.',
|
||||||
|
'authors.edit': 'Change author profiles.',
|
||||||
|
'authors.delete': 'Remove authors.',
|
||||||
|
'activity.view': 'Read the log of who did what.',
|
||||||
|
'activity.export': 'Download the log as a CSV file.',
|
||||||
|
'users.view': 'See who has an account.',
|
||||||
|
'users.invite': 'Send invitations by email.',
|
||||||
|
'users.change_role': 'Give someone another role.',
|
||||||
|
'users.remove': 'Take away someone’s access.',
|
||||||
|
'settings.view': 'Read the site settings.',
|
||||||
|
'settings.edit_general': 'Change the site settings.',
|
||||||
|
'settings.manage_sites': 'Add, edit and remove sites.',
|
||||||
|
'settings.delete_site': 'Remove a site and its content.',
|
||||||
|
'apikeys.view': 'See the API keys.',
|
||||||
|
'apikeys.create': 'Make new API keys.',
|
||||||
|
'apikeys.revoke': 'Stop an API key from working.',
|
||||||
|
'roles.view': 'See roles and what they allow.',
|
||||||
|
'roles.manage': 'Create, edit, duplicate and delete roles.',
|
||||||
|
'roles.edit_owner_only': 'Change a role that carries owner-only abilities.',
|
||||||
|
'system.apply_updates': 'Install a new version of RenewCMS.',
|
||||||
|
'system.edit_retention': 'How long the activity log is kept.',
|
||||||
|
'system.transfer_ownership': 'Make another member the owner.',
|
||||||
|
} as const satisfies Record<keyof typeof NAMES, MessageKey>
|
||||||
|
|
||||||
|
export function permissionDescription(key: string): string | undefined {
|
||||||
|
const description = DESCRIPTIONS[baseKey(key) as keyof typeof DESCRIPTIONS]
|
||||||
|
return description === undefined ? undefined : t(description)
|
||||||
|
}
|
||||||
@@ -29,6 +29,18 @@ export interface ScopeOptions {
|
|||||||
|
|
||||||
const TYPE_SEPARATOR = ':'
|
const TYPE_SEPARATOR = ':'
|
||||||
|
|
||||||
|
export function formatGrantKey(key: string, typeKey?: string) {
|
||||||
|
return typeKey === undefined ? key : `${key}${TYPE_SEPARATOR}${typeKey}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGrantKey(key: string): { base: string; typeKey?: string } {
|
||||||
|
const at = key.indexOf(TYPE_SEPARATOR)
|
||||||
|
if (at === -1) {
|
||||||
|
return { base: key }
|
||||||
|
}
|
||||||
|
return { base: key.slice(0, at), typeKey: key.slice(at + TYPE_SEPARATOR.length) }
|
||||||
|
}
|
||||||
|
|
||||||
function scopeOfGrant(grants: readonly Grant[], key: string): Scope | undefined {
|
function scopeOfGrant(grants: readonly Grant[], key: string): Scope | undefined {
|
||||||
return grants.find((grant) => grant.key === key)?.scope
|
return grants.find((grant) => grant.key === key)?.scope
|
||||||
}
|
}
|
||||||
@@ -39,7 +51,7 @@ export function effectiveScope(
|
|||||||
options: ScopeOptions = {},
|
options: ScopeOptions = {},
|
||||||
): Scope {
|
): Scope {
|
||||||
if (options.typeKey) {
|
if (options.typeKey) {
|
||||||
const typed = scopeOfGrant(grants, `${key}${TYPE_SEPARATOR}${options.typeKey}`)
|
const typed = scopeOfGrant(grants, formatGrantKey(key, options.typeKey))
|
||||||
if (typed !== undefined) {
|
if (typed !== undefined) {
|
||||||
return typed
|
return typed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { formatGrantKey, parseGrantKey, type Grant, type Scope } from '@/lib/permissions'
|
||||||
|
import type { RoleItem } from '@/lib/roles'
|
||||||
|
|
||||||
|
export type GrantMap = Record<string, Scope>
|
||||||
|
|
||||||
|
export interface RoleForm {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
grants: GrantMap
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONTENT_ACTIONS = ['read', 'create', 'update', 'delete', 'publish'] as const
|
||||||
|
export type ContentAction = (typeof CONTENT_ACTIONS)[number]
|
||||||
|
|
||||||
|
export const PRESET_NAMES = ['read_only', 'write_own', 'full_content'] as const
|
||||||
|
export type PresetName = (typeof PRESET_NAMES)[number]
|
||||||
|
|
||||||
|
const CONTENT_PREFIX = 'content.'
|
||||||
|
|
||||||
|
const PRESETS: Record<PresetName, Record<ContentAction, Scope>> = {
|
||||||
|
read_only: { read: 'all', create: 'none', update: 'none', delete: 'none', publish: 'none' },
|
||||||
|
write_own: { read: 'all', create: 'all', update: 'own', delete: 'none', publish: 'none' },
|
||||||
|
full_content: { read: 'all', create: 'all', update: 'all', delete: 'all', publish: 'all' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contentKey(action: ContentAction) {
|
||||||
|
return `${CONTENT_PREFIX}${action}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function typedKey(key: string, typeKey: string) {
|
||||||
|
return formatGrantKey(key, typeKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTyped(key: string) {
|
||||||
|
return parseGrantKey(key).typeKey !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function typedKeysOf(grants: GrantMap, key: string) {
|
||||||
|
return Object.keys(grants).filter((existing) => {
|
||||||
|
const parsed = parseGrantKey(existing)
|
||||||
|
return parsed.typeKey !== undefined && parsed.base === key
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyForm(): RoleForm {
|
||||||
|
return { name: '', description: '', grants: {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formFromRole(role: Pick<RoleItem, 'name' | 'description' | 'grants'>): RoleForm {
|
||||||
|
const grants: GrantMap = {}
|
||||||
|
for (const grant of role.grants) {
|
||||||
|
grants[grant.key] = grant.scope
|
||||||
|
}
|
||||||
|
return { name: role.name, description: role.description, grants }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneForm(form: RoleForm): RoleForm {
|
||||||
|
return { name: form.name, description: form.description, grants: { ...form.grants } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scopeAt(grants: GrantMap, key: string, typeKey?: string): Scope {
|
||||||
|
if (typeKey !== undefined) {
|
||||||
|
const typed = grants[typedKey(key, typeKey)]
|
||||||
|
if (typed !== undefined) {
|
||||||
|
return typed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return grants[key] ?? 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setScope(grants: GrantMap, key: string, scope: Scope, typeKey?: string): GrantMap {
|
||||||
|
const next = { ...grants }
|
||||||
|
if (typeKey !== undefined) {
|
||||||
|
next[typedKey(key, typeKey)] = scope
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
for (const typed of typedKeysOf(next, key)) {
|
||||||
|
delete next[typed]
|
||||||
|
}
|
||||||
|
next[key] = scope
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCustomRow(grants: GrantMap, typeKey: string) {
|
||||||
|
return CONTENT_ACTIONS.some(
|
||||||
|
(action) => grants[typedKey(contentKey(action), typeKey)] !== undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetRow(grants: GrantMap, typeKey: string): GrantMap {
|
||||||
|
const next = { ...grants }
|
||||||
|
for (const action of CONTENT_ACTIONS) {
|
||||||
|
delete next[typedKey(contentKey(action), typeKey)]
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyPreset(grants: GrantMap, preset: PresetName): GrantMap {
|
||||||
|
const next: GrantMap = {}
|
||||||
|
for (const [key, scope] of Object.entries(grants)) {
|
||||||
|
if (!key.startsWith(CONTENT_PREFIX)) {
|
||||||
|
next[key] = scope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const action of CONTENT_ACTIONS) {
|
||||||
|
next[contentKey(action)] = PRESETS[preset][action]
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activePreset(grants: GrantMap): PresetName | null {
|
||||||
|
const hasTyped = Object.keys(grants).some(
|
||||||
|
(key) => key.startsWith(CONTENT_PREFIX) && isTyped(key),
|
||||||
|
)
|
||||||
|
if (hasTyped) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
PRESET_NAMES.find((preset) =>
|
||||||
|
CONTENT_ACTIONS.every(
|
||||||
|
(action) => scopeAt(grants, contentKey(action)) === PRESETS[preset][action],
|
||||||
|
),
|
||||||
|
) ?? null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function grantsToPayload(grants: GrantMap, ownerOnlyKeys: ReadonlySet<string>): Grant[] {
|
||||||
|
return Object.entries(grants)
|
||||||
|
.filter(([key, scope]) => {
|
||||||
|
if (ownerOnlyKeys.has(key)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return isTyped(key) || scope !== 'none'
|
||||||
|
})
|
||||||
|
.map(([key, scope]) => ({ key, scope }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawEntry(grants: GrantMap, key: string): Scope | undefined {
|
||||||
|
return grants[key] ?? (isTyped(key) ? undefined : 'none')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unsavedCount(current: RoleForm, initial: RoleForm): number {
|
||||||
|
let count = 0
|
||||||
|
if (current.name !== initial.name) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if (current.description !== initial.description) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
const keys = new Set([...Object.keys(current.grants), ...Object.keys(initial.grants)])
|
||||||
|
for (const key of keys) {
|
||||||
|
if (rawEntry(current.grants, key) !== rawEntry(initial.grants, key)) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCellChanged(
|
||||||
|
current: GrantMap,
|
||||||
|
initial: GrantMap,
|
||||||
|
key: string,
|
||||||
|
typeKey?: string,
|
||||||
|
): boolean {
|
||||||
|
return scopeAt(current, key, typeKey) !== scopeAt(initial, key, typeKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormDifference {
|
||||||
|
key: string
|
||||||
|
before: Scope | undefined
|
||||||
|
after: Scope | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function differences(ours: RoleForm, theirs: RoleForm): FormDifference[] {
|
||||||
|
const keys = new Set([...Object.keys(ours.grants), ...Object.keys(theirs.grants)])
|
||||||
|
return [...keys]
|
||||||
|
.map((key) => ({
|
||||||
|
key,
|
||||||
|
before: rawEntry(theirs.grants, key),
|
||||||
|
after: rawEntry(ours.grants, key),
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.before !== entry.after)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { t } from '@/i18n'
|
||||||
|
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
|
||||||
|
import type { RoleItem } from '@/lib/roles'
|
||||||
|
|
||||||
|
export interface RoleMenuActions {
|
||||||
|
canManage: boolean
|
||||||
|
edit: (role: RoleItem) => void
|
||||||
|
duplicate: (role: RoleItem) => void
|
||||||
|
remove: (role: RoleItem, trigger: HTMLElement | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOnlyHint() {
|
||||||
|
return t('You can see roles, but only people who manage roles can create or change them.')
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteHint(role: RoleItem, canManage: boolean): string | undefined {
|
||||||
|
if (role.locked) {
|
||||||
|
return t('The Owner role can’t be deleted.')
|
||||||
|
}
|
||||||
|
if (role.built_in) {
|
||||||
|
return t("Built-in roles can't be deleted. Duplicate it to make a custom role.")
|
||||||
|
}
|
||||||
|
return canManage ? undefined : readOnlyHint()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roleMenuItems(role: RoleItem, actions: RoleMenuActions): DirectoryMenuItem[] {
|
||||||
|
const hint = deleteHint(role, actions.canManage)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'edit',
|
||||||
|
label: t('Edit'),
|
||||||
|
icon: 'i-lucide-pencil',
|
||||||
|
onSelect: () => actions.edit(role),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'duplicate',
|
||||||
|
label: t('Duplicate'),
|
||||||
|
icon: 'i-lucide-copy',
|
||||||
|
unavailable: !actions.canManage,
|
||||||
|
hint: actions.canManage ? undefined : readOnlyHint(),
|
||||||
|
onSelect: () => actions.duplicate(role),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'delete',
|
||||||
|
label: t('Delete'),
|
||||||
|
icon: 'i-lucide-trash-2',
|
||||||
|
destructive: true,
|
||||||
|
separated: true,
|
||||||
|
unavailable: hint !== undefined,
|
||||||
|
hint,
|
||||||
|
onSelect: (trigger) => actions.remove(role, trigger),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { t } from '@/i18n'
|
||||||
|
import { CONTENT_ACTIONS, contentKey, scopeAt, type GrantMap } from '@/lib/roleForm'
|
||||||
|
import { parseGrantKey, type Scope } from '@/lib/permissions'
|
||||||
|
|
||||||
|
export interface PreviewType {
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewLine {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
sentence: string
|
||||||
|
changed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RolePreviewModel {
|
||||||
|
content: PreviewLine[]
|
||||||
|
everything: PreviewLine[]
|
||||||
|
noAccess: string | null
|
||||||
|
members: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewInput {
|
||||||
|
grants: GrantMap
|
||||||
|
types: PreviewType[]
|
||||||
|
members?: number | null
|
||||||
|
initial?: GrantMap
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Phrase {
|
||||||
|
key: string
|
||||||
|
all: () => string
|
||||||
|
own?: () => string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Area {
|
||||||
|
id: string
|
||||||
|
label: () => string
|
||||||
|
phrases: Phrase[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const ONLY_THEIR_OWN = (phrase: () => string) => () => `${phrase()}, ${t('only their own')}`
|
||||||
|
|
||||||
|
const AREAS: Area[] = [
|
||||||
|
{
|
||||||
|
id: 'schema',
|
||||||
|
label: () => t('Schema'),
|
||||||
|
phrases: [
|
||||||
|
{ key: 'schema.view', all: () => t('see content types and fields') },
|
||||||
|
{ key: 'schema.edit', all: () => t('create and edit content types') },
|
||||||
|
{ key: 'schema.delete', all: () => t('delete content types') },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'media',
|
||||||
|
label: () => t('Media'),
|
||||||
|
phrases: [
|
||||||
|
{ key: 'media.view', all: () => t('browse the media library') },
|
||||||
|
{ key: 'media.upload', all: () => t('upload files') },
|
||||||
|
{
|
||||||
|
key: 'media.delete',
|
||||||
|
all: () => t('delete files'),
|
||||||
|
own: ONLY_THEIR_OWN(() => t('delete files')),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'authors',
|
||||||
|
label: () => t('Authors'),
|
||||||
|
phrases: [
|
||||||
|
{ key: 'authors.view', all: () => t('see authors') },
|
||||||
|
{ key: 'authors.create', all: () => t('create authors') },
|
||||||
|
{
|
||||||
|
key: 'authors.edit',
|
||||||
|
all: () => t('edit authors'),
|
||||||
|
own: ONLY_THEIR_OWN(() => t('edit authors')),
|
||||||
|
},
|
||||||
|
{ key: 'authors.delete', all: () => t('delete authors') },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'activity',
|
||||||
|
label: () => t('Activity'),
|
||||||
|
phrases: [
|
||||||
|
{
|
||||||
|
key: 'activity.view',
|
||||||
|
all: () => t('see activity'),
|
||||||
|
own: ONLY_THEIR_OWN(() => t('see activity')),
|
||||||
|
},
|
||||||
|
{ key: 'activity.export', all: () => t('export activity') },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'users',
|
||||||
|
label: () => t('Users and invites'),
|
||||||
|
phrases: [
|
||||||
|
{ key: 'users.view', all: () => t('see users') },
|
||||||
|
{ key: 'users.invite', all: () => t('invite users') },
|
||||||
|
{ key: 'users.change_role', all: () => t("change a user's role") },
|
||||||
|
{ key: 'users.remove', all: () => t('remove users') },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'settings',
|
||||||
|
label: () => t('Settings'),
|
||||||
|
phrases: [
|
||||||
|
{ key: 'settings.view', all: () => t('see settings') },
|
||||||
|
{ key: 'settings.edit_general', all: () => t('edit general settings') },
|
||||||
|
{ key: 'settings.manage_sites', all: () => t('manage sites') },
|
||||||
|
{ key: 'settings.delete_site', all: () => t('delete a site') },
|
||||||
|
{
|
||||||
|
key: 'apikeys.view',
|
||||||
|
all: () => t('see API keys'),
|
||||||
|
own: ONLY_THEIR_OWN(() => t('see API keys')),
|
||||||
|
},
|
||||||
|
{ key: 'apikeys.create', all: () => t('create API keys') },
|
||||||
|
{
|
||||||
|
key: 'apikeys.revoke',
|
||||||
|
all: () => t('revoke API keys'),
|
||||||
|
own: ONLY_THEIR_OWN(() => t('revoke API keys')),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'roles',
|
||||||
|
label: () => t('Roles'),
|
||||||
|
phrases: [
|
||||||
|
{ key: 'roles.view', all: () => t('see roles') },
|
||||||
|
{ key: 'roles.manage', all: () => t('manage roles') },
|
||||||
|
{ key: 'roles.edit_owner_only', all: () => t('edit roles with owner-only power') },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'system',
|
||||||
|
label: () => t('System'),
|
||||||
|
phrases: [
|
||||||
|
{ key: 'system.apply_updates', all: () => t('apply software updates') },
|
||||||
|
{ key: 'system.edit_retention', all: () => t('edit activity retention') },
|
||||||
|
{ key: 'system.transfer_ownership', all: () => t('transfer ownership') },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const OWNER_ONLY_AREA = 'system'
|
||||||
|
|
||||||
|
const GRANTED_CONTENT: Record<string, Record<'all' | 'own', () => string>> = {
|
||||||
|
read: { all: () => t('read any entry'), own: () => t('read only their own entries') },
|
||||||
|
create: { all: () => t('create entries'), own: () => t('create entries') },
|
||||||
|
update: { all: () => t('edit any entry'), own: () => t('edit only their own') },
|
||||||
|
delete: { all: () => t('delete any entry'), own: () => t('delete only their own entries') },
|
||||||
|
publish: { all: () => t('publish any entry'), own: () => t('publish only their own entries') },
|
||||||
|
}
|
||||||
|
|
||||||
|
const REFUSED_CONTENT: Record<string, () => string> = {
|
||||||
|
read: () => t('read'),
|
||||||
|
create: () => t('create'),
|
||||||
|
update: () => t('edit'),
|
||||||
|
delete: () => t('delete'),
|
||||||
|
publish: () => t('publish'),
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinList(items: string[]): string {
|
||||||
|
if (items.length <= 1) {
|
||||||
|
return items.join('')
|
||||||
|
}
|
||||||
|
const head = items.slice(0, -1).join(', ')
|
||||||
|
return t('{0} and {1}', [head, items[items.length - 1]])
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentSentence(scopes: Scope[]): string {
|
||||||
|
const granted: string[] = []
|
||||||
|
const refused: string[] = []
|
||||||
|
CONTENT_ACTIONS.forEach((action, index) => {
|
||||||
|
const scope = scopes[index]!
|
||||||
|
if (scope === 'none') {
|
||||||
|
refused.push(REFUSED_CONTENT[action]!())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
granted.push(GRANTED_CONTENT[action]![scope]())
|
||||||
|
})
|
||||||
|
if (granted.length === 0) {
|
||||||
|
return t('No access to entries.')
|
||||||
|
}
|
||||||
|
const can = t('Can {0}.', [joinList(granted)])
|
||||||
|
return refused.length === 0 ? can : `${can} ${t("Can't {0}.", [joinList(refused)])}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentLines(
|
||||||
|
grants: GrantMap,
|
||||||
|
types: PreviewType[],
|
||||||
|
): { id: string; label: string; sentence: string }[] {
|
||||||
|
const hasOverrides = Object.keys(grants).some(
|
||||||
|
(key) => key.startsWith('content.') && parseGrantKey(key).typeKey !== undefined,
|
||||||
|
)
|
||||||
|
if (!hasOverrides || types.length === 0) {
|
||||||
|
const scopes = CONTENT_ACTIONS.map((action) => scopeAt(grants, contentKey(action)))
|
||||||
|
return [{ id: 'all', label: t('All content types'), sentence: contentSentence(scopes) }]
|
||||||
|
}
|
||||||
|
const groups = new Map<string, { names: string[]; scopes: Scope[] }>()
|
||||||
|
for (const type of types) {
|
||||||
|
const scopes = CONTENT_ACTIONS.map((action) =>
|
||||||
|
scopeAt(grants, contentKey(action), type.key),
|
||||||
|
)
|
||||||
|
const signature = scopes.join('|')
|
||||||
|
const group = groups.get(signature) ?? { names: [], scopes }
|
||||||
|
group.names.push(type.name)
|
||||||
|
groups.set(signature, group)
|
||||||
|
}
|
||||||
|
return [...groups.entries()].map(([signature, group]) => ({
|
||||||
|
id: signature,
|
||||||
|
label: group.names.join(', '),
|
||||||
|
sentence: contentSentence(group.scopes),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function areaSentence(area: Area, grants: GrantMap): string | null {
|
||||||
|
const phrases: string[] = []
|
||||||
|
for (const phrase of area.phrases) {
|
||||||
|
const scope = scopeAt(grants, phrase.key)
|
||||||
|
if (scope === 'none') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (scope === 'own') {
|
||||||
|
if (phrase.own) {
|
||||||
|
phrases.push(phrase.own())
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
phrases.push(phrase.all())
|
||||||
|
}
|
||||||
|
return phrases.length === 0 ? null : t('Can {0}.', [joinList(phrases)])
|
||||||
|
}
|
||||||
|
|
||||||
|
function everythingLines(grants: GrantMap) {
|
||||||
|
const lines: { id: string; label: string; sentence: string }[] = []
|
||||||
|
const missing: string[] = []
|
||||||
|
for (const area of AREAS) {
|
||||||
|
const sentence = areaSentence(area, grants)
|
||||||
|
if (sentence !== null) {
|
||||||
|
lines.push({ id: area.id, label: area.label(), sentence })
|
||||||
|
} else if (area.id !== OWNER_ONLY_AREA) {
|
||||||
|
missing.push(area.label())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { lines, missing }
|
||||||
|
}
|
||||||
|
|
||||||
|
function membersLine(members: number | null | undefined): string | null {
|
||||||
|
if (!members) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return members === 1
|
||||||
|
? t('Applies to 1 member, and to the API keys they created.')
|
||||||
|
: t('Applies to {0} members, and to the API keys they created.', [members])
|
||||||
|
}
|
||||||
|
|
||||||
|
function tag(
|
||||||
|
lines: { id: string; label: string; sentence: string }[],
|
||||||
|
before: { id: string; sentence: string }[] | undefined,
|
||||||
|
): PreviewLine[] {
|
||||||
|
return lines.map((line) => ({
|
||||||
|
...line,
|
||||||
|
changed:
|
||||||
|
before !== undefined &&
|
||||||
|
!before.some((old) => old.id === line.id && old.sentence === line.sentence),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRolePreview(input: PreviewInput): RolePreviewModel {
|
||||||
|
const content = contentLines(input.grants, input.types)
|
||||||
|
const everything = everythingLines(input.grants)
|
||||||
|
const before =
|
||||||
|
input.initial === undefined
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
content: contentLines(input.initial, input.types),
|
||||||
|
everything: everythingLines(input.initial).lines,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: tag(content, before?.content),
|
||||||
|
everything: tag(everything.lines, before?.everything),
|
||||||
|
noAccess:
|
||||||
|
everything.missing.length === 0
|
||||||
|
? null
|
||||||
|
: t('No access to {0}.', [everything.missing.join(', ')]),
|
||||||
|
members: membersLine(input.members),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { Grant } from '@/lib/permissions'
|
||||||
|
|
||||||
|
export interface RoleEditor {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoleItem {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
built_in: boolean
|
||||||
|
locked: boolean
|
||||||
|
version: number
|
||||||
|
grants: Grant[]
|
||||||
|
members: number
|
||||||
|
updated_at: string | null
|
||||||
|
updated_by: RoleEditor | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RolePayload {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
grants: Grant[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScopeKind = 'scoped' | 'toggle'
|
||||||
|
|
||||||
|
export interface PermissionItem {
|
||||||
|
key: string
|
||||||
|
scope_kind: ScopeKind
|
||||||
|
owner_only: boolean
|
||||||
|
per_type: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionGroupItem {
|
||||||
|
key: string
|
||||||
|
items: PermissionItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OWNER_ONLY_FALLBACK = new Set([
|
||||||
|
'settings.delete_site',
|
||||||
|
'roles.edit_owner_only',
|
||||||
|
'system.apply_updates',
|
||||||
|
'system.edit_retention',
|
||||||
|
'system.transfer_ownership',
|
||||||
|
])
|
||||||
|
|
||||||
|
export function formatClock(iso: string | null | undefined, language: string): string {
|
||||||
|
if (!iso) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return new Date(iso).toLocaleTimeString(language === 'fr' ? 'fr' : 'en-GB', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { h } from 'vue'
|
||||||
|
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||||
|
import UApp from '@nuxt/ui/components/App.vue'
|
||||||
|
import router from '@/router'
|
||||||
|
import RouteAccessGate from '@/components/RouteAccessGate.vue'
|
||||||
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
import { customAccess } from '@/test/access'
|
||||||
|
|
||||||
|
let wrapper: VueWrapper | null = null
|
||||||
|
|
||||||
|
async function open(grants: string, path: string, role = 'Contributor') {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const session = useSessionStore()
|
||||||
|
session.session = { id: 4, verified: true, displayName: '', access: customAccess(grants, role) }
|
||||||
|
session.status = 'ready'
|
||||||
|
await router.push(path).catch(() => undefined)
|
||||||
|
wrapper = mount(
|
||||||
|
() =>
|
||||||
|
h(UApp, null, () =>
|
||||||
|
h(RouteAccessGate, null, () => h('div', { 'data-testid': 'page' }, 'PAGE')),
|
||||||
|
),
|
||||||
|
{ attachTo: document.body, global: { plugins: [router, uiPlugin] } },
|
||||||
|
)
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
wrapper = null
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
await router.push('/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Route access', () => {
|
||||||
|
it.each([
|
||||||
|
['roles.view', '/settings/roles', true],
|
||||||
|
['roles.view', '/settings/roles/new', false],
|
||||||
|
['roles.view, roles.manage', '/settings/roles/new', true],
|
||||||
|
['roles.view', '/settings/roles/4', true],
|
||||||
|
['content.read=all', '/settings/roles', false],
|
||||||
|
['content.read=all', '/settings/roles/4', false],
|
||||||
|
])('%s opening %s renders the page: %s', async (grants, path, allowed) => {
|
||||||
|
const view = await open(grants, path)
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.fullPath).toBe(path)
|
||||||
|
expect(view.find('[data-testid="page"]').exists()).toBe(allowed)
|
||||||
|
if (!allowed) {
|
||||||
|
expect(view.text()).toContain("You don't have access to this page")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the page-level 403 with Go to Articles to a Contributor', async () => {
|
||||||
|
const view = await open('content.read=all', '/settings/roles')
|
||||||
|
|
||||||
|
expect(view.text()).toContain(
|
||||||
|
"Your role, Contributor, doesn't include it. Ask an owner or admin if you need it.",
|
||||||
|
)
|
||||||
|
expect(view.text()).toContain('Go to Articles')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('/settings/permissions is retired', () => {
|
||||||
|
it('replaces the URL with /settings/roles and flashes no 403', async () => {
|
||||||
|
const view = await open('roles.view', '/settings/permissions')
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.fullPath).toBe('/settings/roles')
|
||||||
|
expect(view.find('[data-testid="page"]').exists()).toBe(true)
|
||||||
|
expect(view.text()).not.toContain("You don't have access to this page")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects even for someone who cannot see roles, to the same 403 as /settings/roles', async () => {
|
||||||
|
await open('content.read=all', '/settings/permissions')
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.fullPath).toBe('/settings/roles')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('the session is still being checked', () => {
|
||||||
|
it('keeps the boot screen instead of the 403 state', async () => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const session = useSessionStore()
|
||||||
|
session.status = 'checking'
|
||||||
|
await router.push('/settings/roles').catch(() => undefined)
|
||||||
|
wrapper = mount(
|
||||||
|
() =>
|
||||||
|
h(UApp, null, () =>
|
||||||
|
h(RouteAccessGate, null, () => h('div', { 'data-testid': 'page' })),
|
||||||
|
),
|
||||||
|
{ attachTo: document.body, global: { plugins: [router, uiPlugin] } },
|
||||||
|
)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain("You don't have access to this page")
|
||||||
|
})
|
||||||
|
})
|
||||||
+20
-5
@@ -9,14 +9,10 @@ import {
|
|||||||
} from 'vue-router'
|
} from 'vue-router'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
import { installDocumentTitle } from '@/composables/documentTitle'
|
import { installDocumentTitle } from '@/composables/documentTitle'
|
||||||
import { ARTICLE_TYPE_KEY, type RouteAccess } from '@/lib/routeAccess'
|
import { ARTICLE_TYPE_KEY } from '@/lib/routeAccess'
|
||||||
|
|
||||||
export type AuthRequirement = 'public' | 'session' | 'verified' | 'any'
|
export type AuthRequirement = 'public' | 'session' | 'verified' | 'any'
|
||||||
|
|
||||||
export function routeAccessOf(route: RouteLocationNormalized): RouteAccess | undefined {
|
|
||||||
return route.meta.access as RouteAccess | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const gatedViews = new Map<string, Promise<Component>>()
|
const gatedViews = new Map<string, Promise<Component>>()
|
||||||
|
|
||||||
function gated(key: string, load: () => Promise<{ default: Component }>) {
|
function gated(key: string, load: () => Promise<{ default: Component }>) {
|
||||||
@@ -226,6 +222,25 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: gated('@/views/SitesView.vue', () => import('@/views/SitesView.vue')),
|
component: gated('@/views/SitesView.vue', () => import('@/views/SitesView.vue')),
|
||||||
meta: { auth: 'verified', title: 'Settings', access: { key: 'settings.manage_sites' } },
|
meta: { auth: 'verified', title: 'Settings', access: { key: 'settings.manage_sites' } },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/settings/permissions',
|
||||||
|
redirect: '/settings/roles',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings/roles',
|
||||||
|
component: gated('@/views/RolesListView.vue', () => import('@/views/RolesListView.vue')),
|
||||||
|
meta: { auth: 'verified', title: 'Roles', access: { key: 'roles.view' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings/roles/new',
|
||||||
|
component: gated('@/views/RoleEditorView.vue', () => import('@/views/RoleEditorView.vue')),
|
||||||
|
meta: { auth: 'verified', title: 'Roles', access: { key: 'roles.manage' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings/roles/:id',
|
||||||
|
component: gated('@/views/RoleEditorView.vue', () => import('@/views/RoleEditorView.vue')),
|
||||||
|
meta: { auth: 'verified', title: 'Roles', access: { key: 'roles.view' } },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/settings/media/cache',
|
path: '/settings/media/cache',
|
||||||
component: gated('@/views/CachePage.vue', () => import('@/views/CachePage.vue')),
|
component: gated('@/views/CachePage.vue', () => import('@/views/CachePage.vue')),
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useMediaQuery } from '@vueuse/core'
|
||||||
|
import DashboardShell from '@/components/DashboardShell.vue'
|
||||||
|
import PermissionGrid from '@/components/PermissionGrid.vue'
|
||||||
|
import PermissionGroup from '@/components/PermissionGroup.vue'
|
||||||
|
import PresetPills from '@/components/PresetPills.vue'
|
||||||
|
import RoleNotices from '@/components/RoleNotices.vue'
|
||||||
|
import RolePreview from '@/components/RolePreview.vue'
|
||||||
|
import RoleSaveBar from '@/components/RoleSaveBar.vue'
|
||||||
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||||
|
import { useLocalizedOverlay } from '@/i18n/presentation'
|
||||||
|
import { useRoleEditor } from '@/composables/useRoleEditor'
|
||||||
|
import type { CallerScope } from '@/lib/grantLocks'
|
||||||
|
import { usePermissionsStore } from '@/stores/permissions'
|
||||||
|
|
||||||
|
const DESKTOP_QUERY = '(min-width: 64rem)'
|
||||||
|
const CONTENT_GROUP = 'content'
|
||||||
|
|
||||||
|
const permissions = usePermissionsStore()
|
||||||
|
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||||
|
const editor = useRoleEditor()
|
||||||
|
const overlay = useLocalizedOverlay()
|
||||||
|
|
||||||
|
const sheetOpen = ref(false)
|
||||||
|
const openGroups = ref<string[]>([])
|
||||||
|
|
||||||
|
const callerScope: CallerScope = (key, options) => permissions.scopeOf(key, options)
|
||||||
|
|
||||||
|
const generalGroups = computed(() =>
|
||||||
|
editor.catalogue.value.filter((group) => group.key !== CONTENT_GROUP),
|
||||||
|
)
|
||||||
|
const allOpen = computed(
|
||||||
|
() => generalGroups.value.length > 0 && openGroups.value.length === generalGroups.value.length,
|
||||||
|
)
|
||||||
|
const title = computed(() => (editor.isNew.value ? t('New role') : (editor.role.value?.name ?? '')))
|
||||||
|
const warnMembers = computed(() => editor.role.value?.built_in === true && !editor.readOnly.value)
|
||||||
|
const canDuplicate = computed(() => permissions.can('roles.manage'))
|
||||||
|
const footerVisible = computed(
|
||||||
|
() => editor.barVisible.value || (!isDesktop.value && editor.loadState.value === 'ready'),
|
||||||
|
)
|
||||||
|
const fieldsDisabled = computed(() => editor.readOnly.value || editor.saving.value)
|
||||||
|
const typeLabel = computed(() => {
|
||||||
|
const role = editor.role.value
|
||||||
|
if (!role) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return role.built_in ? t('Built-in') : t('Custom')
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleGroup(key: string, open: boolean) {
|
||||||
|
openGroups.value = open
|
||||||
|
? [...openGroups.value.filter((existing) => existing !== key), key]
|
||||||
|
: openGroups.value.filter((existing) => existing !== key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll() {
|
||||||
|
openGroups.value = allOpen.value ? [] : generalGroups.value.map((group) => group.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmReset() {
|
||||||
|
const target = editor.role.value
|
||||||
|
if (!target) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
overlay
|
||||||
|
.create(ConfirmDialog, {
|
||||||
|
props: {
|
||||||
|
get title() {
|
||||||
|
return t('Reset {0} to default?', [target.name])
|
||||||
|
},
|
||||||
|
get description() {
|
||||||
|
return t('Its permissions go back to what RenewCMS ships with.')
|
||||||
|
},
|
||||||
|
get confirmLabel() {
|
||||||
|
return t('Reset to default')
|
||||||
|
},
|
||||||
|
get cancelLabel() {
|
||||||
|
return t('Cancel')
|
||||||
|
},
|
||||||
|
get pendingLabel() {
|
||||||
|
return t('Resetting…')
|
||||||
|
},
|
||||||
|
tone: 'danger',
|
||||||
|
action: editor.resetRole,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(editor.load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DashboardShell>
|
||||||
|
<div
|
||||||
|
v-if="editor.loadState.value === 'loading'"
|
||||||
|
class="flex flex-col gap-3"
|
||||||
|
aria-busy="true"
|
||||||
|
>
|
||||||
|
<USkeleton class="h-8 w-60 rounded-none bg-accented" />
|
||||||
|
<USkeleton class="h-40 w-full rounded-none bg-accented" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-else-if="editor.loadState.value === 'error'"
|
||||||
|
role="alert"
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
orientation="vertical"
|
||||||
|
:close="false"
|
||||||
|
icon="i-lucide-circle-x"
|
||||||
|
:title="t('Couldn\'t load this role')"
|
||||||
|
:description="t('Check your connection and try again.')"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<UButton color="neutral" variant="outline" class="h-8" @click="editor.load">
|
||||||
|
{{ t('Try again') }}
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</UAlert>
|
||||||
|
|
||||||
|
<UEmpty
|
||||||
|
v-else-if="editor.loadState.value === 'missing'"
|
||||||
|
variant="naked"
|
||||||
|
icon="i-lucide-shield"
|
||||||
|
:title="t('This role no longer exists')"
|
||||||
|
:description="t('It may have been deleted.')"
|
||||||
|
:ui="{ root: 'gap-3 border border-default bg-default px-6 py-14' }"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<UButton color="neutral" variant="outline" to="/settings/roles">
|
||||||
|
{{ t('Back to Roles') }}
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</UEmpty>
|
||||||
|
|
||||||
|
<div v-else class="flex flex-col gap-4" :class="{ 'pb-20': !isDesktop }">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<UButton
|
||||||
|
to="/settings/roles"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
icon="i-lucide-arrow-left"
|
||||||
|
:aria-label="t('Back to Roles')"
|
||||||
|
class="size-8 justify-center max-md:size-11"
|
||||||
|
/>
|
||||||
|
<h1 class="text-xl font-semibold text-highlighted">{{ title }}</h1>
|
||||||
|
<template v-if="editor.role.value">
|
||||||
|
<UBadge
|
||||||
|
v-if="editor.role.value.locked"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-lock"
|
||||||
|
:label="t('Locked')"
|
||||||
|
/>
|
||||||
|
<UBadge color="neutral" variant="subtle" :label="typeLabel" />
|
||||||
|
<span class="text-muted">{{
|
||||||
|
editor.role.value.members === 1
|
||||||
|
? t('1 member')
|
||||||
|
: t('{0} members', [editor.role.value.members])
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
<UButton
|
||||||
|
v-if="editor.canReset.value"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
class="ms-auto h-8 px-3"
|
||||||
|
@click="confirmReset"
|
||||||
|
>
|
||||||
|
{{ t('Reset to default') }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RoleNotices
|
||||||
|
:role="editor.role.value"
|
||||||
|
:reason="editor.readOnlyReason.value"
|
||||||
|
:conflict="editor.conflict.value"
|
||||||
|
:refusal="editor.refusal.value"
|
||||||
|
:can-duplicate="canDuplicate"
|
||||||
|
:warn-members="warnMembers"
|
||||||
|
:reviewing="editor.reviewing.value"
|
||||||
|
:differences="editor.differenceRows.value"
|
||||||
|
@duplicate="editor.duplicate"
|
||||||
|
@reload-role="editor.reloadRole"
|
||||||
|
@reload-page="editor.reloadPage"
|
||||||
|
@review="editor.toggleReview"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_22rem]">
|
||||||
|
<div
|
||||||
|
class="flex min-w-0 flex-col gap-6"
|
||||||
|
:class="{ 'opacity-60': editor.saving.value }"
|
||||||
|
:aria-busy="editor.saving.value ? 'true' : undefined"
|
||||||
|
>
|
||||||
|
<section class="flex flex-col gap-4">
|
||||||
|
<UFormField
|
||||||
|
:label="t('Name')"
|
||||||
|
:error="editor.nameError.value || undefined"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
:model-value="editor.form.value.name"
|
||||||
|
:placeholder="t('For example, Contributor')"
|
||||||
|
:disabled="fieldsDisabled"
|
||||||
|
class="w-full"
|
||||||
|
@update:model-value="editor.setName(String($event))"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<UFormField :label="t('Description (optional)')">
|
||||||
|
<UInput
|
||||||
|
:model-value="editor.form.value.description"
|
||||||
|
:placeholder="t('What this role is for')"
|
||||||
|
:disabled="fieldsDisabled"
|
||||||
|
class="w-full"
|
||||||
|
@update:model-value="editor.setDescription(String($event))"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<h2 class="text-base font-semibold text-highlighted">
|
||||||
|
{{ t('Content permissions') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
'What this role can do with entries of each content type. Own means entries the person created.',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<PresetPills
|
||||||
|
:active="editor.preset.value"
|
||||||
|
:disabled="editor.readOnly.value"
|
||||||
|
@select="editor.choosePreset"
|
||||||
|
/>
|
||||||
|
<PermissionGrid
|
||||||
|
:types="editor.gridTypes.value"
|
||||||
|
:grants="editor.form.value.grants"
|
||||||
|
:initial="editor.initial.value.grants"
|
||||||
|
:caller-scope="callerScope"
|
||||||
|
:read-only="editor.readOnly.value"
|
||||||
|
:mobile="!isDesktop"
|
||||||
|
@change="editor.changeCell"
|
||||||
|
@reset="editor.resetType"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-wrap items-end justify-between gap-2">
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<h2 class="text-base font-semibold text-highlighted">
|
||||||
|
{{ t('General permissions') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted">{{ t('Everything outside entries.') }}</p>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
class="h-8 px-3 max-md:h-11"
|
||||||
|
@click="toggleAll"
|
||||||
|
>
|
||||||
|
{{ allOpen ? t('Collapse all') : t('Expand all') }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
<div class="border border-default bg-default">
|
||||||
|
<PermissionGroup
|
||||||
|
v-for="group in generalGroups"
|
||||||
|
:key="group.key"
|
||||||
|
:open="openGroups.includes(group.key)"
|
||||||
|
:group="group"
|
||||||
|
:grants="editor.form.value.grants"
|
||||||
|
:initial="editor.initial.value.grants"
|
||||||
|
:caller-scope="callerScope"
|
||||||
|
:read-only="editor.readOnly.value"
|
||||||
|
@update:open="toggleGroup(group.key, $event)"
|
||||||
|
@change="editor.changeGeneral"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside v-if="isDesktop" class="min-w-0">
|
||||||
|
<div class="sticky top-4">
|
||||||
|
<RolePreview
|
||||||
|
:model="editor.preview.value"
|
||||||
|
:read-only="editor.readOnly.value"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="footerVisible" class="sticky bottom-0 z-10 -mx-4 mt-4 flex flex-col">
|
||||||
|
<UButton
|
||||||
|
v-if="!isDesktop"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
block
|
||||||
|
class="h-11 justify-center"
|
||||||
|
@click="sheetOpen = true"
|
||||||
|
>
|
||||||
|
{{ t('What this role can do') }}
|
||||||
|
</UButton>
|
||||||
|
<RoleSaveBar
|
||||||
|
v-if="editor.barVisible.value"
|
||||||
|
:count="editor.unsaved.value"
|
||||||
|
:problems="editor.problems.value"
|
||||||
|
:saving="editor.saving.value"
|
||||||
|
:conflicted="editor.conflict.value !== null"
|
||||||
|
:can-save="editor.canSave.value"
|
||||||
|
:name-missing="editor.nameMissing.value"
|
||||||
|
:is-new="editor.isNew.value"
|
||||||
|
@discard="editor.discard"
|
||||||
|
@save="editor.save"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RolePreview
|
||||||
|
v-if="!isDesktop"
|
||||||
|
v-model:open="sheetOpen"
|
||||||
|
mobile
|
||||||
|
:model="editor.preview.value"
|
||||||
|
:read-only="editor.readOnly.value"
|
||||||
|
/>
|
||||||
|
</DashboardShell>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useMediaQuery } from '@vueuse/core'
|
||||||
|
import { useLocalizedToast } from '@/i18n/presentation'
|
||||||
|
import DashboardShell from '@/components/DashboardShell.vue'
|
||||||
|
import DeleteRoleDialog from '@/components/DeleteRoleDialog.vue'
|
||||||
|
import ReadOnlyBanner from '@/components/ReadOnlyBanner.vue'
|
||||||
|
import RolesList from '@/components/RolesList.vue'
|
||||||
|
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
|
||||||
|
import type { LoadState } from '@/composables/useRoleEditor'
|
||||||
|
import { useRoles } from '@/composables/useRoles'
|
||||||
|
import { canCover } from '@/lib/grantLocks'
|
||||||
|
import type { RoleItem } from '@/lib/roles'
|
||||||
|
import { usePermissionsStore } from '@/stores/permissions'
|
||||||
|
|
||||||
|
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useLocalizedToast()
|
||||||
|
const permissions = usePermissionsStore()
|
||||||
|
const { listRoles } = useRoles()
|
||||||
|
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||||
|
const skeleton = useDelayedSkeleton()
|
||||||
|
|
||||||
|
const loadState = ref<LoadState>('loading')
|
||||||
|
const roles = ref<RoleItem[]>([])
|
||||||
|
const deleting = ref<RoleItem | null>(null)
|
||||||
|
const dialogOpen = ref(false)
|
||||||
|
|
||||||
|
const canManage = computed(() => permissions.can('roles.manage'))
|
||||||
|
const onlyBuiltIn = computed(
|
||||||
|
() => loadState.value === 'ready' && roles.value.every((role) => role.built_in),
|
||||||
|
)
|
||||||
|
const showList = computed(
|
||||||
|
() => loadState.value === 'ready' || (loadState.value === 'loading' && skeleton.visible.value),
|
||||||
|
)
|
||||||
|
const newRoleDisabled = computed(() => !canManage.value || loadState.value === 'loading')
|
||||||
|
|
||||||
|
function canAssign(role: RoleItem) {
|
||||||
|
return canCover(role, permissions.scopeOf)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loadState.value = 'loading'
|
||||||
|
skeleton.start()
|
||||||
|
try {
|
||||||
|
roles.value = await listRoles()
|
||||||
|
loadState.value = 'ready'
|
||||||
|
} catch {
|
||||||
|
loadState.value = 'error'
|
||||||
|
} finally {
|
||||||
|
skeleton.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goNew() {
|
||||||
|
void router.push('/settings/roles/new')
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = computed(() => ({
|
||||||
|
canManage: canManage.value,
|
||||||
|
edit: (role: RoleItem) => void router.push(`/settings/roles/${role.id}`),
|
||||||
|
duplicate: (role: RoleItem) =>
|
||||||
|
void router.push({ path: '/settings/roles/new', query: { from: String(role.id) } }),
|
||||||
|
remove: (role: RoleItem) => {
|
||||||
|
deleting.value = role
|
||||||
|
dialogOpen.value = true
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
function onDeleted(role: RoleItem) {
|
||||||
|
roles.value = roles.value.filter((item) => item.id !== role.id)
|
||||||
|
toast.add(() => ({
|
||||||
|
id: `role-deleted-${role.id}`,
|
||||||
|
color: 'success',
|
||||||
|
icon: 'i-lucide-circle-check',
|
||||||
|
get title() {
|
||||||
|
return t('Role deleted')
|
||||||
|
},
|
||||||
|
get description() {
|
||||||
|
return t('{0} is gone.', [role.name])
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDialogOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
deleting.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDuplicate(role: RoleItem) {
|
||||||
|
void router.push({ path: '/settings/roles/new', query: { from: String(role.id) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DashboardShell>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-wrap items-end gap-4">
|
||||||
|
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
|
||||||
|
<h1 class="text-xl font-semibold text-highlighted">{{ t('Roles') }}</h1>
|
||||||
|
<p class="text-muted">
|
||||||
|
{{ t('Who can do what on this site. Each user has one role.') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<UTooltip
|
||||||
|
:text="
|
||||||
|
t(
|
||||||
|
'You can see roles, but only people who manage roles can create or change them.',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:disabled="canManage"
|
||||||
|
>
|
||||||
|
<span class="inline-flex" :tabindex="canManage ? undefined : 0">
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-plus"
|
||||||
|
class="h-8 px-3 max-md:h-11"
|
||||||
|
:disabled="newRoleDisabled"
|
||||||
|
@click="goNew"
|
||||||
|
>
|
||||||
|
{{ t('New role') }}
|
||||||
|
</UButton>
|
||||||
|
</span>
|
||||||
|
</UTooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReadOnlyBanner
|
||||||
|
v-if="!canManage"
|
||||||
|
:title="t('You can see roles but not change them')"
|
||||||
|
:description="t('Ask an owner or admin if a role needs to change.')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="loadState === 'error'"
|
||||||
|
role="alert"
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
orientation="vertical"
|
||||||
|
:close="false"
|
||||||
|
icon="i-lucide-circle-x"
|
||||||
|
:title="t('Couldn\'t load roles')"
|
||||||
|
:description="t('GET /v1/role failed. Check your connection and try again.')"
|
||||||
|
:ui="{ actions: 'mt-2' }"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-rotate-cw"
|
||||||
|
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
|
||||||
|
@click="load"
|
||||||
|
>
|
||||||
|
{{ t('Try again') }}
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</UAlert>
|
||||||
|
|
||||||
|
<RolesList
|
||||||
|
v-if="showList"
|
||||||
|
:roles="roles"
|
||||||
|
:loading="loadState === 'loading'"
|
||||||
|
:desktop="isDesktop"
|
||||||
|
:actions="actions"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UEmpty
|
||||||
|
v-if="onlyBuiltIn"
|
||||||
|
variant="naked"
|
||||||
|
icon="i-lucide-shield"
|
||||||
|
:title="t('No custom roles yet')"
|
||||||
|
:description="
|
||||||
|
t(
|
||||||
|
'The three built-in roles cover most sites. Create a role when someone needs something in between.',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:ui="{
|
||||||
|
root: 'gap-3 border border-default bg-default px-6 py-10',
|
||||||
|
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
|
||||||
|
title: 'text-md/7 font-semibold',
|
||||||
|
description: 'max-w-90 text-muted text-pretty',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-plus"
|
||||||
|
class="h-8 px-4 max-md:h-11"
|
||||||
|
:disabled="!canManage"
|
||||||
|
@click="goNew"
|
||||||
|
>
|
||||||
|
{{ t('New role') }}
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</UEmpty>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeleteRoleDialog
|
||||||
|
v-if="deleting"
|
||||||
|
:key="deleting.id"
|
||||||
|
v-model:open="dialogOpen"
|
||||||
|
:role="deleting"
|
||||||
|
:roles="roles"
|
||||||
|
:can-assign="canAssign"
|
||||||
|
@update:open="onDialogOpenChange"
|
||||||
|
@deleted="onDeleted"
|
||||||
|
@gone="load"
|
||||||
|
@duplicate="onDuplicate"
|
||||||
|
/>
|
||||||
|
</DashboardShell>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,881 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import router from '@/router'
|
||||||
|
import { ApiError } from '@/composables/useApi'
|
||||||
|
import RoleEditorView from '../RoleEditorView.vue'
|
||||||
|
import { radiosOf } from '@/components/__tests__/roleKit'
|
||||||
|
import {
|
||||||
|
Backend,
|
||||||
|
CATALOGUE,
|
||||||
|
buttonNamed,
|
||||||
|
grants,
|
||||||
|
mountRoleView,
|
||||||
|
role,
|
||||||
|
textOf,
|
||||||
|
type Handler,
|
||||||
|
} from './roleKit'
|
||||||
|
import { adminAccess, customAccess, ownerAccess } from '@/test/access'
|
||||||
|
import type { Access } from '@/lib/permissions'
|
||||||
|
import type { RoleItem } from '@/lib/roles'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
const toastAdd = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||||
|
return { ...actual, useToast: () => ({ add: toastAdd }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const JONAS = { id: 2, name: 'Jonas Weber' }
|
||||||
|
|
||||||
|
const CONTRIBUTOR = role({
|
||||||
|
id: 4,
|
||||||
|
name: 'Contributor',
|
||||||
|
description: 'Creates content and edits only their own.',
|
||||||
|
version: 2,
|
||||||
|
members: 4,
|
||||||
|
grants: grants('content.read, content.create, content.update=own, content.update:case-studies'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const WRITER = role({
|
||||||
|
id: 3,
|
||||||
|
name: 'Writer',
|
||||||
|
built_in: true,
|
||||||
|
version: 1,
|
||||||
|
members: 5,
|
||||||
|
grants: grants('content.read, content.create, content.update'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const OWNER_ROLE = role({ id: 1, name: 'Owner', built_in: true, locked: true, members: 1 })
|
||||||
|
|
||||||
|
const TYPES = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
key: 'events',
|
||||||
|
name: 'Events',
|
||||||
|
icon: null,
|
||||||
|
fields: [],
|
||||||
|
created_at: '',
|
||||||
|
updated_at: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
key: 'case-studies',
|
||||||
|
name: 'Case studies',
|
||||||
|
icon: null,
|
||||||
|
fields: [],
|
||||||
|
created_at: '',
|
||||||
|
updated_at: '',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
let wrapper: VueWrapper | null = null
|
||||||
|
|
||||||
|
function refuse(status: number, code: string, details: Record<string, string> = {}) {
|
||||||
|
return () => {
|
||||||
|
throw new ApiError(status, undefined, code, { code: code as never, ...details })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function backendFor(loaded: RoleItem | null, extra: Record<string, Handler> = {}) {
|
||||||
|
return new Backend({
|
||||||
|
'GET /v1/permission/': () => ({ groups: CATALOGUE }),
|
||||||
|
'GET /v1/content-type': () => TYPES,
|
||||||
|
...(loaded ? { [`GET /v1/role/${loaded.id}`]: () => loaded } : {}),
|
||||||
|
...extra,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(
|
||||||
|
path: string,
|
||||||
|
backend: Backend,
|
||||||
|
access: Access = ownerAccess(),
|
||||||
|
options: { desktop?: boolean; userId?: number } = {},
|
||||||
|
) {
|
||||||
|
wrapper = await mountRoleView(RoleEditorView, path, { access, backend, ...options })
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
toastAdd.mockReset()
|
||||||
|
Element.prototype.scrollIntoView = vi.fn()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
wrapper = null
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
await router.push('/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
function page() {
|
||||||
|
return textOf(document.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nameInput() {
|
||||||
|
return document.querySelector<HTMLInputElement>(
|
||||||
|
'input[placeholder="For example, Contributor"]',
|
||||||
|
)!
|
||||||
|
}
|
||||||
|
|
||||||
|
function descriptionInput() {
|
||||||
|
return document.querySelector<HTMLInputElement>('input[placeholder="What this role is for"]')!
|
||||||
|
}
|
||||||
|
|
||||||
|
async function type(input: HTMLInputElement, value: string) {
|
||||||
|
input.value = value
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function choose(name: string, scope: 'None' | 'Own' | 'All') {
|
||||||
|
const radio = radiosOf(document.body, name).find((candidate) => textOf(candidate) === scope)!
|
||||||
|
radio.click()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle(name: string) {
|
||||||
|
document.body.querySelector<HTMLElement>(`[role="switch"][aria-label="${name}"]`)!.click()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function press(label: string) {
|
||||||
|
buttonNamed(label)!.click()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
function checked(name: string) {
|
||||||
|
return textOf(
|
||||||
|
radiosOf(document.body, name).find(
|
||||||
|
(radio) => radio.getAttribute('aria-checked') === 'true',
|
||||||
|
)!,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveBar() {
|
||||||
|
return document.body.querySelector('[data-testid="role-save-bar"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toastTitles() {
|
||||||
|
return toastAdd.mock.calls.map((call) => ({
|
||||||
|
title: call[0].title,
|
||||||
|
description: call[0].description,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Role editor: New role (empty)', () => {
|
||||||
|
it('starts with nothing granted, a disabled Save and the hint', async () => {
|
||||||
|
await open('/settings/roles/new', backendFor(null))
|
||||||
|
|
||||||
|
const text = page()
|
||||||
|
expect(text).toContain('New role')
|
||||||
|
expect(text).toContain('Name your role to save it')
|
||||||
|
expect(buttonNamed('Save changes')!.disabled).toBe(true)
|
||||||
|
expect(nameInput().value).toBe('')
|
||||||
|
expect(descriptionInput().value).toBe('')
|
||||||
|
for (const copy of [
|
||||||
|
'Content permissions',
|
||||||
|
'Quick presets',
|
||||||
|
'Read only',
|
||||||
|
'Write own',
|
||||||
|
'Full content',
|
||||||
|
'All content types',
|
||||||
|
'Articles',
|
||||||
|
'Events',
|
||||||
|
'General permissions',
|
||||||
|
'Expand all',
|
||||||
|
]) {
|
||||||
|
expect(text).toContain(copy)
|
||||||
|
}
|
||||||
|
expect(checked('All content types: Read')).toBe('None')
|
||||||
|
expect(checked('Events: Delete')).toBe('None')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Role editor: Editing a custom role', () => {
|
||||||
|
it('fills the fields and shows the member count, Custom and the Case studies Reset link', async () => {
|
||||||
|
await open('/settings/roles/4', backendFor(CONTRIBUTOR))
|
||||||
|
|
||||||
|
expect(nameInput().value).toBe('Contributor')
|
||||||
|
expect(descriptionInput().value).toBe('Creates content and edits only their own.')
|
||||||
|
expect(page()).toContain('4 members')
|
||||||
|
expect(page()).toContain('Custom')
|
||||||
|
const row = document.body.querySelector('[data-testid="row-case-studies"]')!
|
||||||
|
expect(textOf(row)).toContain('Custom')
|
||||||
|
expect(buttonNamed('Reset', row)).toBeDefined()
|
||||||
|
expect(saveBar()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Reset removes the typed grants and the row follows All content types again', async () => {
|
||||||
|
await open('/settings/roles/4', backendFor(CONTRIBUTOR))
|
||||||
|
expect(checked('Case studies: Update')).toBe('All')
|
||||||
|
|
||||||
|
buttonNamed(
|
||||||
|
'Reset',
|
||||||
|
document.body.querySelector('[data-testid="row-case-studies"]')!,
|
||||||
|
)!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(checked('Case studies: Update')).toBe('Own')
|
||||||
|
expect(
|
||||||
|
textOf(document.body.querySelector('[data-testid="row-case-studies"]')!),
|
||||||
|
).not.toContain('Custom')
|
||||||
|
expect(page()).toContain('1 unsaved change')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Role editor: Unsaved changes', () => {
|
||||||
|
it('counts three cell changes, marks them, tags the preview and Discard restores', async () => {
|
||||||
|
await open('/settings/roles/4', backendFor(CONTRIBUTOR))
|
||||||
|
|
||||||
|
await choose('Events: Read', 'None')
|
||||||
|
await choose('Events: Delete', 'Own')
|
||||||
|
await toggle('Events: Create')
|
||||||
|
|
||||||
|
expect(page()).toContain('3 unsaved changes')
|
||||||
|
expect(
|
||||||
|
document.body.querySelectorAll('[data-testid="changed-marker"]').length,
|
||||||
|
).toBeGreaterThanOrEqual(3)
|
||||||
|
expect(page()).toContain('Changed')
|
||||||
|
|
||||||
|
await press('Discard')
|
||||||
|
|
||||||
|
expect(saveBar()).toBeNull()
|
||||||
|
expect(checked('Events: Read')).toBe('All')
|
||||||
|
expect(checked('Events: Delete')).toBe('None')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Role editor: Saving then saved', () => {
|
||||||
|
it('sends the PUT with the current version, dims while in flight and closes the bar on 200', async () => {
|
||||||
|
let release: (value: unknown) => void = () => undefined
|
||||||
|
const inFlight = new Promise((resolve) => {
|
||||||
|
release = resolve
|
||||||
|
})
|
||||||
|
const backend = backendFor(CONTRIBUTOR, { 'PUT /v1/role/4': () => inFlight })
|
||||||
|
await open('/settings/roles/4', backend)
|
||||||
|
await choose('All content types: Update', 'All')
|
||||||
|
|
||||||
|
await press('Save changes')
|
||||||
|
|
||||||
|
const put = backend.callsTo('PUT', '/v1/role/4')[0]!
|
||||||
|
expect(put.body).toEqual({
|
||||||
|
name: 'Contributor',
|
||||||
|
description: 'Creates content and edits only their own.',
|
||||||
|
grants: [
|
||||||
|
{ key: 'content.read', scope: 'all' },
|
||||||
|
{ key: 'content.create', scope: 'all' },
|
||||||
|
{ key: 'content.update', scope: 'all' },
|
||||||
|
],
|
||||||
|
version: 2,
|
||||||
|
})
|
||||||
|
expect(page()).toContain('Saving…')
|
||||||
|
expect(buttonNamed('Discard')!.disabled).toBe(true)
|
||||||
|
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
|
||||||
|
|
||||||
|
release({
|
||||||
|
...CONTRIBUTOR,
|
||||||
|
version: 3,
|
||||||
|
grants: grants('content.read, content.create, content.update'),
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(saveBar()).toBeNull()
|
||||||
|
expect(toastTitles()).toEqual([
|
||||||
|
{ title: 'Role saved', description: 'Contributor is updated for 4 members.' },
|
||||||
|
])
|
||||||
|
await choose('All content types: Update', 'Own')
|
||||||
|
await press('Save changes')
|
||||||
|
expect((backend.callsTo('PUT', '/v1/role/4')[1]!.body as { version: number }).version).toBe(
|
||||||
|
3,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Role editor: Creating', () => {
|
||||||
|
it.each([
|
||||||
|
['nothing granted', false, []],
|
||||||
|
[
|
||||||
|
'the Write own preset',
|
||||||
|
true,
|
||||||
|
[
|
||||||
|
{ key: 'content.read', scope: 'all' },
|
||||||
|
{ key: 'content.create', scope: 'all' },
|
||||||
|
{ key: 'content.update', scope: 'own' },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
])('saves a new role named Contributor with %s', async (_label, preset, expected) => {
|
||||||
|
const backend = backendFor(null, {
|
||||||
|
'POST /v1/role/': () => role({ id: 9, name: 'Contributor', version: 1 }),
|
||||||
|
'GET /v1/role/9': () => role({ id: 9, name: 'Contributor', version: 1 }),
|
||||||
|
})
|
||||||
|
await open('/settings/roles/new', backend)
|
||||||
|
await type(nameInput(), 'Contributor')
|
||||||
|
if (preset) {
|
||||||
|
await press('Write own')
|
||||||
|
}
|
||||||
|
|
||||||
|
await press('Save changes')
|
||||||
|
|
||||||
|
const post = backend.callsTo('POST', '/v1/role/')[0]!
|
||||||
|
expect(post.body).toEqual({ name: 'Contributor', description: '', grants: expected })
|
||||||
|
expect(toastTitles()).toEqual([
|
||||||
|
{ title: 'Role created', description: 'Contributor is ready. Assign it from Users.' },
|
||||||
|
])
|
||||||
|
await vi.waitFor(() => expect(router.currentRoute.value.fullPath).toBe('/settings/roles/9'))
|
||||||
|
expect(saveBar()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Role editor: Presets', () => {
|
||||||
|
it.each([
|
||||||
|
['Read only', ['All', 'None', 'None', 'None']],
|
||||||
|
['Write own', ['All', 'On', 'Own', 'None']],
|
||||||
|
['Full content', ['All', 'On', 'All', 'All']],
|
||||||
|
])('%s sets every type row and counts unsaved changes', async (preset, expected) => {
|
||||||
|
await open('/settings/roles/new', backendFor(null))
|
||||||
|
|
||||||
|
await press(preset)
|
||||||
|
|
||||||
|
expect(buttonNamed(preset)!.getAttribute('aria-pressed')).toBe('true')
|
||||||
|
for (const type of ['Events', 'Articles']) {
|
||||||
|
const [read, create, update, deleteScope] = expected
|
||||||
|
expect(checked(`${type}: Read`)).toBe(read)
|
||||||
|
const create_ = document.body.querySelector(
|
||||||
|
`[role="switch"][aria-label="${type}: Create"]`,
|
||||||
|
)!
|
||||||
|
expect(create_.getAttribute('aria-checked')).toBe(create === 'On' ? 'true' : 'false')
|
||||||
|
expect(checked(`${type}: Update`)).toBe(update === 'On' ? 'All' : update)
|
||||||
|
expect(checked(`${type}: Delete`)).toBe(deleteScope)
|
||||||
|
}
|
||||||
|
expect(page()).toMatch(/\d+ unsaved changes?/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Role editor: Errors on save', () => {
|
||||||
|
async function failWith(handler: Handler, edit?: () => Promise<void>) {
|
||||||
|
const backend = backendFor(CONTRIBUTOR, { 'PUT /v1/role/4': handler })
|
||||||
|
await open('/settings/roles/4', backend)
|
||||||
|
await choose('Events: Delete', 'Own')
|
||||||
|
await edit?.()
|
||||||
|
await press('Save changes')
|
||||||
|
return backend
|
||||||
|
}
|
||||||
|
|
||||||
|
it('sends nothing for an empty name and reads the field error and the count', async () => {
|
||||||
|
const backend = await failWith(
|
||||||
|
() => undefined,
|
||||||
|
() => type(nameInput(), ''),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(backend.callsTo('PUT', '/v1/role/4')).toHaveLength(0)
|
||||||
|
expect(page()).toContain('Give the role a name.')
|
||||||
|
expect(page()).toContain('1 problem to fix')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps 400 invalid_role_name to the same field error and keeps the edits', async () => {
|
||||||
|
await failWith(refuse(400, 'invalid_role_name'))
|
||||||
|
|
||||||
|
expect(page()).toContain('Give the role a name.')
|
||||||
|
expect(checked('Events: Delete')).toBe('Own')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps 409 role_name_taken to the named field error', async () => {
|
||||||
|
await failWith(refuse(409, 'role_name_taken'), () => type(nameInput(), 'Writer'))
|
||||||
|
|
||||||
|
expect(page()).toContain('A role named "Writer" already exists. Choose a different name.')
|
||||||
|
expect(page()).toContain('1 problem to fix')
|
||||||
|
expect(nameInput().value).toBe('Writer')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps 400 built_in_name to the built-in message', async () => {
|
||||||
|
await failWith(refuse(400, 'built_in_name'))
|
||||||
|
|
||||||
|
expect(page()).toContain('Built-in roles keep their name.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the Conflict state after re-reading the role, and Reload role replaces the form', async () => {
|
||||||
|
let reads = 0
|
||||||
|
const backend = new Backend({
|
||||||
|
'GET /v1/permission/': () => ({ groups: CATALOGUE }),
|
||||||
|
'GET /v1/content-type': () => TYPES,
|
||||||
|
'GET /v1/role/4': () =>
|
||||||
|
++reads === 1
|
||||||
|
? CONTRIBUTOR
|
||||||
|
: {
|
||||||
|
...CONTRIBUTOR,
|
||||||
|
version: 3,
|
||||||
|
name: 'Contributor',
|
||||||
|
grants: grants('content.read'),
|
||||||
|
updated_at: '2026-10-27T11:02:00Z',
|
||||||
|
updated_by: JONAS,
|
||||||
|
},
|
||||||
|
'PUT /v1/role/4': refuse(409, 'role_conflict'),
|
||||||
|
})
|
||||||
|
await open('/settings/roles/4', backend)
|
||||||
|
await choose('Events: Delete', 'Own')
|
||||||
|
await choose('Events: Read', 'None')
|
||||||
|
|
||||||
|
await press('Save changes')
|
||||||
|
|
||||||
|
expect(page()).toContain('This role changed while you were editing')
|
||||||
|
expect(page()).toContain(
|
||||||
|
'Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits.',
|
||||||
|
)
|
||||||
|
expect(page()).toContain('2 unsaved changes. Save is off until you reload')
|
||||||
|
expect(buttonNamed('Save changes')!.disabled).toBe(true)
|
||||||
|
expect(buttonNamed('Review differences')).toBeDefined()
|
||||||
|
expect(checked('Events: Delete')).toBe('Own')
|
||||||
|
|
||||||
|
await press('Review differences')
|
||||||
|
expect(document.body.querySelector('[data-testid="role-differences"]')).not.toBeNull()
|
||||||
|
|
||||||
|
await press('Reload role')
|
||||||
|
|
||||||
|
expect(page()).not.toContain('This role changed while you were editing')
|
||||||
|
expect(checked('Events: Delete')).toBe('None')
|
||||||
|
expect(saveBar()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('turns the page read-only with Your access changed on 403 missing_permission, and Reload page refreshes', async () => {
|
||||||
|
const backend = await failWith(
|
||||||
|
refuse(403, 'missing_permission', { permission: 'roles.manage' }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(page()).toContain('Your access changed')
|
||||||
|
expect(page()).toContain(
|
||||||
|
"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.",
|
||||||
|
)
|
||||||
|
expect(saveBar()).toBeNull()
|
||||||
|
expect(radiosOf(document.body, 'Events: Read')[0]!.getAttribute('aria-disabled')).toBe(
|
||||||
|
'true',
|
||||||
|
)
|
||||||
|
expect(nameInput().disabled).toBe(true)
|
||||||
|
expect(buttonNamed('Reload page')).toBeDefined()
|
||||||
|
|
||||||
|
await press('Reload page')
|
||||||
|
|
||||||
|
expect(backend.callsTo('GET', '/v1/role/4').length).toBeGreaterThanOrEqual(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the escalation refusal and keeps the edits', async () => {
|
||||||
|
await failWith(refuse(403, 'escalation', { escalationKey: 'content.publish' }))
|
||||||
|
|
||||||
|
expect(page()).toContain("You can't grant that")
|
||||||
|
expect(page()).toContain('You can only grant what your role has.')
|
||||||
|
expect(page()).toContain('Publish entries')
|
||||||
|
expect(checked('Events: Delete')).toBe('Own')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('turns read-only with This role is locked on 403 role_locked', async () => {
|
||||||
|
await failWith(refuse(403, 'role_locked'))
|
||||||
|
|
||||||
|
expect(page()).toContain('This role is locked')
|
||||||
|
expect(page()).toContain(
|
||||||
|
"The Owner role can't be changed or deleted. Transfer ownership from Users instead.",
|
||||||
|
)
|
||||||
|
expect(saveBar()).toBeNull()
|
||||||
|
expect(nameInput().disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("shows You can't change your own role on 403 own_role", async () => {
|
||||||
|
await failWith(refuse(403, 'own_role'))
|
||||||
|
|
||||||
|
expect(page()).toContain("You can't change your own role")
|
||||||
|
expect(page()).toContain('Ask another owner or admin.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a generic toast for another failure', async () => {
|
||||||
|
await failWith(refuse(500, 'boom'))
|
||||||
|
|
||||||
|
expect(toastTitles()).toHaveLength(1)
|
||||||
|
expect(saveBar()).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Role editor: Read-only editor', () => {
|
||||||
|
it('warns the owner about the 5 members of Writer, tags Built-in and offers Reset to default', async () => {
|
||||||
|
await open('/settings/roles/3', backendFor(WRITER))
|
||||||
|
|
||||||
|
expect(page()).toContain('This role has 5 members')
|
||||||
|
expect(page()).toContain(
|
||||||
|
'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.',
|
||||||
|
)
|
||||||
|
expect(page()).toContain('Built-in')
|
||||||
|
expect(nameInput().disabled).toBe(false)
|
||||||
|
expect(buttonNamed('Reset to default')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('locks everything for an admin and offers Duplicate role', async () => {
|
||||||
|
await open('/settings/roles/3', backendFor(WRITER), adminAccess(), { userId: 7 })
|
||||||
|
|
||||||
|
expect(page()).toContain('Only the owner can edit built-in roles')
|
||||||
|
expect(page()).toContain(
|
||||||
|
'You can read what Writer allows. To change it, duplicate it into a custom role.',
|
||||||
|
)
|
||||||
|
expect(nameInput().disabled).toBe(true)
|
||||||
|
expect(radiosOf(document.body, 'Events: Read')[0]!.getAttribute('aria-disabled')).toBe(
|
||||||
|
'true',
|
||||||
|
)
|
||||||
|
expect(saveBar()).toBeNull()
|
||||||
|
expect(page()).not.toContain('Reset to default')
|
||||||
|
expect(page()).not.toContain('Updates as you edit.')
|
||||||
|
expect(page()).toContain('A summary of what this role allows.')
|
||||||
|
|
||||||
|
await press('Duplicate role')
|
||||||
|
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/settings/roles/new'))
|
||||||
|
await vi.waitFor(() => expect(nameInput().disabled).toBe(false))
|
||||||
|
expect(checked('All content types: Update')).toBe('All')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the banner and no Save bar for roles.view only', async () => {
|
||||||
|
await open(
|
||||||
|
'/settings/roles/4',
|
||||||
|
backendFor(CONTRIBUTOR),
|
||||||
|
customAccess('roles.view', 'Viewer', 9),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(document.body.querySelector('[data-testid="read-only-banner"]')).not.toBeNull()
|
||||||
|
expect(nameInput().disabled).toBe(true)
|
||||||
|
expect(saveBar()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads the own-role message when the person opens their own role', async () => {
|
||||||
|
await open(
|
||||||
|
'/settings/roles/4',
|
||||||
|
backendFor(CONTRIBUTOR),
|
||||||
|
customAccess('roles.view, roles.manage', 'Contributor', 4),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(page()).toContain(
|
||||||
|
"You can't change the permissions of your own role. Ask another owner or admin.",
|
||||||
|
)
|
||||||
|
expect(nameInput().disabled).toBe(true)
|
||||||
|
expect(saveBar()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('locks the Owner role for anyone with roles.view and offers Go to Users', async () => {
|
||||||
|
await open('/settings/roles/1', backendFor(OWNER_ROLE), ownerAccess())
|
||||||
|
|
||||||
|
expect(page()).toContain('The Owner role is locked')
|
||||||
|
expect(page()).toContain(
|
||||||
|
'It always has every permission. To hand it to someone else, transfer ownership from Users.',
|
||||||
|
)
|
||||||
|
expect(page()).toContain('Locked')
|
||||||
|
expect(buttonNamed('Go to Users')).toBeDefined()
|
||||||
|
expect(nameInput().disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gates owner-only editing on the permission, not on a role name', async () => {
|
||||||
|
const renamed = { ...WRITER, id: 8, name: 'Renamed built-in' }
|
||||||
|
await open('/settings/roles/8', backendFor(renamed), adminAccess(), { userId: 7 })
|
||||||
|
|
||||||
|
expect(page()).toContain('Only the owner can edit built-in roles')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Role editor: General permissions', () => {
|
||||||
|
it('lists the groups with "x of y enabled" and Expand all opens them', async () => {
|
||||||
|
const loaded = { ...CONTRIBUTOR, grants: grants('activity.view, activity.export') }
|
||||||
|
await open('/settings/roles/4', backendFor(loaded))
|
||||||
|
|
||||||
|
expect(page()).toContain('Activity')
|
||||||
|
expect(page()).toContain('2 of 2 enabled')
|
||||||
|
expect(page()).not.toContain('Read the log of who did what.')
|
||||||
|
|
||||||
|
await press('Expand all')
|
||||||
|
|
||||||
|
expect(page()).toContain('See activity')
|
||||||
|
expect(page()).toContain('Read the log of who did what.')
|
||||||
|
expect(page()).toContain('Export activity')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Role editor: Owner-only items', () => {
|
||||||
|
it('keeps them disabled and off for an admin and never sends their key', async () => {
|
||||||
|
const backend = backendFor(CONTRIBUTOR, {
|
||||||
|
'PUT /v1/role/4': () => ({ ...CONTRIBUTOR, version: 3 }),
|
||||||
|
})
|
||||||
|
await open('/settings/roles/4', backend, adminAccess(), { userId: 7 })
|
||||||
|
await press('Expand all')
|
||||||
|
|
||||||
|
const toggleSwitch = document.body.querySelector(
|
||||||
|
'[role="switch"][aria-label="Apply software updates"]',
|
||||||
|
)!
|
||||||
|
expect(toggleSwitch.getAttribute('aria-checked')).toBe('false')
|
||||||
|
expect(toggleSwitch.hasAttribute('disabled')).toBe(true)
|
||||||
|
expect(document.body.querySelector('[data-testid="owner-only-tag"]')).not.toBeNull()
|
||||||
|
|
||||||
|
await choose('Events: Delete', 'Own')
|
||||||
|
await press('Save changes')
|
||||||
|
|
||||||
|
const sent = JSON.stringify(backend.callsTo('PUT', '/v1/role/4')[0]!.body)
|
||||||
|
expect(sent).not.toContain('system.apply_updates')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Role editor: Escalation locks', () => {
|
||||||
|
it('locks Publish All when the session can publish only its own, and keeps None and Own', async () => {
|
||||||
|
await open(
|
||||||
|
'/settings/roles/new',
|
||||||
|
backendFor(null),
|
||||||
|
customAccess(
|
||||||
|
'roles.view, roles.manage, schema.view, content.read, content.publish=own',
|
||||||
|
'Editor',
|
||||||
|
9,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const radios = radiosOf(document.body, 'Events: Publish')
|
||||||
|
const all = radios.find((radio) => textOf(radio) === 'All')!
|
||||||
|
expect(all.getAttribute('aria-disabled')).toBe('true')
|
||||||
|
expect(document.getElementById(all.getAttribute('aria-describedby')!)!.textContent).toBe(
|
||||||
|
"You can only grant what your role has. Your role can publish only entries you created, so All isn't available.",
|
||||||
|
)
|
||||||
|
for (const label of ['None', 'Own']) {
|
||||||
|
expect(
|
||||||
|
radios.find((radio) => textOf(radio) === label)!.getAttribute('aria-disabled'),
|
||||||
|
).toBeNull()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('locks a cell for a typed override in the session', async () => {
|
||||||
|
await open(
|
||||||
|
'/settings/roles/new',
|
||||||
|
backendFor(null),
|
||||||
|
customAccess(
|
||||||
|
'roles.view, roles.manage, schema.view, content.read, content.update, content.update:events=none',
|
||||||
|
'Editor',
|
||||||
|
9,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
radiosOf(document.body, 'Events: Update')
|
||||||
|
.find((radio) => textOf(radio) === 'Own')!
|
||||||
|
.getAttribute('aria-disabled'),
|
||||||
|
).toBe('true')
|
||||||
|
expect(
|
||||||
|
radiosOf(document.body, 'Case studies: Update')
|
||||||
|
.find((radio) => textOf(radio) === 'Own')!
|
||||||
|
.getAttribute('aria-disabled'),
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Role editor: Plain-language preview', () => {
|
||||||
|
it('updates live and tags the changed line', async () => {
|
||||||
|
await open(
|
||||||
|
'/settings/roles/4',
|
||||||
|
backendFor({
|
||||||
|
...CONTRIBUTOR,
|
||||||
|
grants: grants('content.read, content.create, content.update=own'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const preview = () => textOf(document.body.querySelector('[data-testid="role-preview"]')!)
|
||||||
|
|
||||||
|
expect(preview()).toContain('What this role can do')
|
||||||
|
expect(preview()).toContain('Updates as you edit.')
|
||||||
|
expect(preview()).toContain(
|
||||||
|
"Can read any entry, create entries and edit only their own. Can't delete and publish.",
|
||||||
|
)
|
||||||
|
expect(preview()).toContain('Applies to 4 members, and to the API keys they created.')
|
||||||
|
expect(preview()).not.toContain('Changed')
|
||||||
|
|
||||||
|
await choose('All content types: Update', 'All')
|
||||||
|
|
||||||
|
expect(preview()).toContain('edit any entry')
|
||||||
|
expect(preview()).toContain('Changed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads No access to entries for a new role with nothing granted', async () => {
|
||||||
|
await open('/settings/roles/new', backendFor(null))
|
||||||
|
|
||||||
|
const preview = textOf(document.body.querySelector('[data-testid="role-preview"]')!)
|
||||||
|
expect(preview).toContain('No access to entries.')
|
||||||
|
expect(preview).toContain(
|
||||||
|
'No access to Schema, Media, Authors, Activity, Users and invites, Settings, Roles.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens a bottom sheet with Close from the sticky bar on mobile and draws one card per type', async () => {
|
||||||
|
await open('/settings/roles/new', backendFor(null), ownerAccess(), { desktop: false })
|
||||||
|
|
||||||
|
expect(document.body.querySelector('[data-testid="card-events"]')).not.toBeNull()
|
||||||
|
expect(document.body.querySelector('[role="grid"]')).toBeNull()
|
||||||
|
|
||||||
|
await press('What this role can do')
|
||||||
|
|
||||||
|
const sheet = document.body.querySelector('[role="dialog"]')!
|
||||||
|
expect(textOf(sheet)).toContain('Updates as you edit.')
|
||||||
|
buttonNamed('Close', sheet)!.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Role editor: Duplicate', () => {
|
||||||
|
it('opens prefilled with the source grants, an empty name, and saves with no built_in or locked field', async () => {
|
||||||
|
const backend = backendFor(WRITER, {
|
||||||
|
'POST /v1/role/': () => role({ id: 10, name: 'Writer copy' }),
|
||||||
|
'GET /v1/role/10': () => role({ id: 10, name: 'Writer copy' }),
|
||||||
|
})
|
||||||
|
await open('/settings/roles/new?from=3', backend, adminAccess(), { userId: 7 })
|
||||||
|
|
||||||
|
expect(nameInput().value).toBe('')
|
||||||
|
expect(checked('All content types: Read')).toBe('All')
|
||||||
|
expect(checked('All content types: Update')).toBe('All')
|
||||||
|
expect(router.currentRoute.value.fullPath).toBe('/settings/roles/new')
|
||||||
|
|
||||||
|
await type(nameInput(), 'Writer copy')
|
||||||
|
await press('Save changes')
|
||||||
|
|
||||||
|
const body = backend.callsTo('POST', '/v1/role/')[0]!.body as Record<string, unknown>
|
||||||
|
expect(Object.keys(body).sort()).toEqual(['description', 'grants', 'name'])
|
||||||
|
expect(body.grants).toEqual([
|
||||||
|
{ key: 'content.read', scope: 'all' },
|
||||||
|
{ key: 'content.create', scope: 'all' },
|
||||||
|
{ key: 'content.update', scope: 'all' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Role editor: stale responses', () => {
|
||||||
|
it('drops a slow response for a role the user already navigated away from', async () => {
|
||||||
|
let release: (value: RoleItem) => void = () => {}
|
||||||
|
const slow = new Promise<RoleItem>((resolve) => {
|
||||||
|
release = resolve
|
||||||
|
})
|
||||||
|
const backend = backendFor(CONTRIBUTOR, { 'GET /v1/role/3': () => slow })
|
||||||
|
await open('/settings/roles/3', backend)
|
||||||
|
|
||||||
|
await router.push('/settings/roles/4')
|
||||||
|
await flushPromises()
|
||||||
|
expect(nameInput().value).toBe('Contributor')
|
||||||
|
|
||||||
|
release(WRITER)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(nameInput().value).toBe('Contributor')
|
||||||
|
expect(buttonNamed('Reset to default')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Role editor: Reset to default', () => {
|
||||||
|
it('is absent for a custom role and for people without roles.edit_owner_only', async () => {
|
||||||
|
await open('/settings/roles/4', backendFor(CONTRIBUTOR))
|
||||||
|
expect(buttonNamed('Reset to default')).toBeUndefined()
|
||||||
|
wrapper!.unmount()
|
||||||
|
|
||||||
|
await open('/settings/roles/3', backendFor(WRITER), adminAccess(), { userId: 7 })
|
||||||
|
expect(buttonNamed('Reset to default')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('posts the reset with the version and takes the seeded grants', async () => {
|
||||||
|
const seeded = { ...WRITER, version: 2, grants: grants('content.read') }
|
||||||
|
const backend = backendFor(WRITER, { 'POST /v1/role/3/reset': () => seeded })
|
||||||
|
await open('/settings/roles/3', backend)
|
||||||
|
|
||||||
|
await press('Reset to default')
|
||||||
|
const confirm = Array.from(
|
||||||
|
document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'),
|
||||||
|
).find((candidate) => textOf(candidate) === 'Reset to default')!
|
||||||
|
confirm.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(backend.callsTo('POST', '/v1/role/3/reset')[0]!.body).toEqual({ version: 1 })
|
||||||
|
expect(checked('All content types: Update')).toBe('None')
|
||||||
|
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the Conflict state on 409 role_conflict', async () => {
|
||||||
|
const backend = backendFor(WRITER, {
|
||||||
|
'POST /v1/role/3/reset': refuse(409, 'role_conflict'),
|
||||||
|
})
|
||||||
|
await open('/settings/roles/3', backend)
|
||||||
|
|
||||||
|
await press('Reset to default')
|
||||||
|
Array.from(document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'))
|
||||||
|
.find((candidate) => textOf(candidate) === 'Reset to default')!
|
||||||
|
.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(page()).toContain('This role changed while you were editing')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Role editor: Create a Contributor in under a minute', () => {
|
||||||
|
it('follows the six steps', async () => {
|
||||||
|
const backend = backendFor(null, {
|
||||||
|
'POST /v1/role/': () => role({ id: 11, name: 'Contributor' }),
|
||||||
|
'GET /v1/role/11': () => role({ id: 11, name: 'Contributor' }),
|
||||||
|
})
|
||||||
|
await open('/settings/roles/new', backend)
|
||||||
|
|
||||||
|
await type(nameInput(), 'Contributor')
|
||||||
|
await type(descriptionInput(), 'Writes posts and edits only their own')
|
||||||
|
await press('Write own')
|
||||||
|
expect(page()).toContain(
|
||||||
|
"Can read any entry, create entries and edit only their own. Can't delete and publish.",
|
||||||
|
)
|
||||||
|
await press('Save changes')
|
||||||
|
|
||||||
|
expect(backend.callsTo('POST', '/v1/role/')[0]!.body).toEqual({
|
||||||
|
name: 'Contributor',
|
||||||
|
description: 'Writes posts and edits only their own',
|
||||||
|
grants: [
|
||||||
|
{ key: 'content.read', scope: 'all' },
|
||||||
|
{ key: 'content.create', scope: 'all' },
|
||||||
|
{ key: 'content.update', scope: 'own' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(toastTitles()).toEqual([
|
||||||
|
{ title: 'Role created', description: 'Contributor is ready. Assign it from Users.' },
|
||||||
|
])
|
||||||
|
expect(saveBar()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Role editor: missing role', () => {
|
||||||
|
it('offers Back to Roles when the role is gone', async () => {
|
||||||
|
const backend = backendFor(null, { 'GET /v1/role/99': refuse(404, 'role_not_found') })
|
||||||
|
await open('/settings/roles/99', backend)
|
||||||
|
|
||||||
|
const back = Array.from(document.querySelectorAll('a')).find(
|
||||||
|
(link) => link.textContent?.trim() === 'Back to Roles',
|
||||||
|
)
|
||||||
|
expect(back?.getAttribute('href')).toBe('/settings/roles')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Role editor: unknown reset failure', () => {
|
||||||
|
it('toasts the reset failure and keeps the grants', async () => {
|
||||||
|
const backend = backendFor(WRITER, {
|
||||||
|
'POST /v1/role/3/reset': () => {
|
||||||
|
throw new Error('network down')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await open('/settings/roles/3', backend)
|
||||||
|
|
||||||
|
await press('Reset to default')
|
||||||
|
Array.from(document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'))
|
||||||
|
.find((candidate) => textOf(candidate) === 'Reset to default')!
|
||||||
|
.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const added = toastAdd.mock.calls.map((call) =>
|
||||||
|
typeof call[0] === 'function' ? call[0]() : call[0],
|
||||||
|
)
|
||||||
|
expect(added).toHaveLength(1)
|
||||||
|
expect(added[0]).toMatchObject({
|
||||||
|
id: 'role-reset-failed',
|
||||||
|
color: 'error',
|
||||||
|
title: "Couldn't reset the role. Try again in a moment.",
|
||||||
|
})
|
||||||
|
expect(page()).not.toContain('This role changed while you were editing')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import router from '@/router'
|
||||||
|
import { ApiError } from '@/composables/useApi'
|
||||||
|
import RolesListView from '../RolesListView.vue'
|
||||||
|
import { Backend, buttonNamed, grants, mountRoleView, role, textOf } from './roleKit'
|
||||||
|
import { customAccess, ownerAccess, adminAccess } from '@/test/access'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||||
|
return { ...actual, useToast: () => ({ add: vi.fn() }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const OWNER = role({
|
||||||
|
id: 1,
|
||||||
|
name: 'Owner',
|
||||||
|
built_in: true,
|
||||||
|
locked: true,
|
||||||
|
members: 1,
|
||||||
|
description: 'Everything.',
|
||||||
|
})
|
||||||
|
const ADMIN = role({
|
||||||
|
id: 2,
|
||||||
|
name: 'Admin',
|
||||||
|
built_in: true,
|
||||||
|
members: 2,
|
||||||
|
description: 'Everything except owner-only.',
|
||||||
|
})
|
||||||
|
const WRITER = role({
|
||||||
|
id: 3,
|
||||||
|
name: 'Writer',
|
||||||
|
built_in: true,
|
||||||
|
members: 5,
|
||||||
|
description: 'Writes content.',
|
||||||
|
})
|
||||||
|
const CONTRIBUTOR = role({
|
||||||
|
id: 4,
|
||||||
|
name: 'Contributor',
|
||||||
|
members: 4,
|
||||||
|
description: 'Creates content and edits only their own.',
|
||||||
|
grants: grants('content.read, content.update=own'),
|
||||||
|
})
|
||||||
|
const LEAD = role({
|
||||||
|
id: 5,
|
||||||
|
name: 'Content lead',
|
||||||
|
members: 1,
|
||||||
|
description: 'Edits and publishes all content.',
|
||||||
|
})
|
||||||
|
|
||||||
|
let wrapper: VueWrapper | null = null
|
||||||
|
|
||||||
|
function listBackend(items = [OWNER, ADMIN, WRITER, CONTRIBUTOR, LEAD], extra = {}) {
|
||||||
|
return new Backend({ 'GET /v1/role/': () => items, ...extra })
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
wrapper = null
|
||||||
|
vi.useRealTimers()
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
await router.push('/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Element.prototype.scrollIntoView = vi.fn()
|
||||||
|
})
|
||||||
|
|
||||||
|
function rows() {
|
||||||
|
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-testid="role-row"]'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowOf(name: string) {
|
||||||
|
return rows().find((row) => textOf(row).startsWith(name))!
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMenu(name: string) {
|
||||||
|
rowOf(name).querySelector<HTMLElement>('button[aria-label^="Actions for"]')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function item(items: HTMLElement[], label: string) {
|
||||||
|
return items.find((candidate) => textOf(candidate).startsWith(label))!
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Roles list: Default', () => {
|
||||||
|
it('shows the header, the columns and a row per role with its type badge', async () => {
|
||||||
|
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||||
|
access: ownerAccess(),
|
||||||
|
backend: listBackend(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const text = textOf(document.body)
|
||||||
|
expect(text).toContain('Who can do what on this site. Each user has one role.')
|
||||||
|
for (const column of ['Role', 'Description', 'Members', 'Type']) {
|
||||||
|
expect(text).toContain(column)
|
||||||
|
}
|
||||||
|
expect(rows()).toHaveLength(5)
|
||||||
|
expect(textOf(rowOf('Owner'))).toContain('Locked')
|
||||||
|
expect(textOf(rowOf('Owner'))).toContain('Built-in')
|
||||||
|
expect(textOf(rowOf('Writer'))).toContain('Built-in')
|
||||||
|
expect(textOf(rowOf('Contributor'))).toContain('Custom')
|
||||||
|
expect(textOf(rowOf('Contributor'))).toContain('4')
|
||||||
|
expect(textOf(rowOf('Content lead'))).toContain('Custom')
|
||||||
|
expect(buttonNamed('New role')!.disabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('draws each role as a card on mobile', async () => {
|
||||||
|
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||||
|
access: ownerAccess(),
|
||||||
|
backend: listBackend(),
|
||||||
|
desktop: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(document.body.querySelectorAll('[data-testid="role-card"]')).toHaveLength(5)
|
||||||
|
expect(rows()).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens a role from its name', async () => {
|
||||||
|
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||||
|
access: ownerAccess(),
|
||||||
|
backend: listBackend(),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(rowOf('Contributor').querySelector('a')!.getAttribute('href')).toBe(
|
||||||
|
'/settings/roles/4',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Roles list: Loading, only built-ins, error', () => {
|
||||||
|
it('shows skeleton rows after 150 ms with New role disabled, then the list', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
let release: (value: unknown) => void = () => undefined
|
||||||
|
const pending = new Promise((resolve) => {
|
||||||
|
release = resolve
|
||||||
|
})
|
||||||
|
const backend = new Backend({ 'GET /v1/role/': () => pending })
|
||||||
|
const mounting = mountRoleView(RolesListView, '/settings/roles', {
|
||||||
|
access: ownerAccess(),
|
||||||
|
backend,
|
||||||
|
})
|
||||||
|
await vi.advanceTimersByTimeAsync(100)
|
||||||
|
expect(document.body.querySelectorAll('[data-testid="role-skeleton"]')).toHaveLength(0)
|
||||||
|
await vi.advanceTimersByTimeAsync(100)
|
||||||
|
wrapper = await mounting
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
document.body.querySelectorAll('[data-testid="role-skeleton"]').length,
|
||||||
|
).toBeGreaterThan(0)
|
||||||
|
expect(buttonNamed('New role')!.disabled).toBe(true)
|
||||||
|
|
||||||
|
release([OWNER, ADMIN, WRITER, CONTRIBUTOR])
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(document.body.querySelectorAll('[data-testid="role-skeleton"]')).toHaveLength(0)
|
||||||
|
expect(rows()).toHaveLength(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads No custom roles yet when only the built-ins are listed', async () => {
|
||||||
|
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||||
|
access: ownerAccess(),
|
||||||
|
backend: listBackend([OWNER, ADMIN, WRITER]),
|
||||||
|
})
|
||||||
|
|
||||||
|
const text = textOf(document.body)
|
||||||
|
expect(text).toContain('No custom roles yet')
|
||||||
|
expect(text).toContain(
|
||||||
|
'The three built-in roles cover most sites. Create a role when someone needs something in between.',
|
||||||
|
)
|
||||||
|
expect(buttonNamed('New role')!.disabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("reads Couldn't load roles on a failure and Try again re-requests the list", async () => {
|
||||||
|
let attempts = 0
|
||||||
|
const backend = new Backend({
|
||||||
|
'GET /v1/role/': () => {
|
||||||
|
attempts++
|
||||||
|
if (attempts === 1) {
|
||||||
|
throw new ApiError(500)
|
||||||
|
}
|
||||||
|
return [OWNER, ADMIN, WRITER, CONTRIBUTOR]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||||
|
access: ownerAccess(),
|
||||||
|
backend,
|
||||||
|
})
|
||||||
|
|
||||||
|
const text = textOf(document.body)
|
||||||
|
expect(text).toContain("Couldn't load roles")
|
||||||
|
expect(text).toContain('GET /v1/role failed. Check your connection and try again.')
|
||||||
|
|
||||||
|
buttonNamed('Try again')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(backend.callsTo('GET', '/v1/role/')).toHaveLength(2)
|
||||||
|
expect(rows()).toHaveLength(4)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Roles list: No permission to edit', () => {
|
||||||
|
it('disables New role and shows the banner for roles.view without roles.manage', async () => {
|
||||||
|
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||||
|
access: customAccess('roles.view', 'Viewer'),
|
||||||
|
backend: listBackend(),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(buttonNamed('New role')!.disabled).toBe(true)
|
||||||
|
const banner = document.body.querySelector('[data-testid="read-only-banner"]')!
|
||||||
|
expect(textOf(banner)).toContain('You can see roles but not change them')
|
||||||
|
expect(textOf(banner)).toContain('Ask an owner or admin if a role needs to change.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows no banner to someone who manages roles', async () => {
|
||||||
|
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||||
|
access: ownerAccess(),
|
||||||
|
backend: listBackend(),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(document.body.querySelector('[data-testid="read-only-banner"]')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Roles list: Row menu', () => {
|
||||||
|
it('offers Edit, Duplicate and an enabled Delete on a custom role', async () => {
|
||||||
|
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||||
|
access: customAccess('roles.view, roles.manage', 'Manager', 9),
|
||||||
|
backend: listBackend(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = await openMenu('Contributor')
|
||||||
|
|
||||||
|
expect(items.map((entry) => textOf(entry).split(' ')[0])).toEqual([
|
||||||
|
'Edit',
|
||||||
|
'Duplicate',
|
||||||
|
'Delete',
|
||||||
|
])
|
||||||
|
expect(item(items, 'Delete').getAttribute('aria-disabled')).not.toBe('true')
|
||||||
|
expect(item(items, 'Duplicate').getAttribute('aria-disabled')).not.toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables Delete on a built-in role with the reason', async () => {
|
||||||
|
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||||
|
access: ownerAccess(),
|
||||||
|
backend: listBackend(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = await openMenu('Writer')
|
||||||
|
|
||||||
|
expect(item(items, 'Delete').getAttribute('aria-disabled')).toBe('true')
|
||||||
|
expect(item(items, 'Delete').getAttribute('aria-describedby')).toMatch(/\S+/)
|
||||||
|
expect(
|
||||||
|
document.getElementById(item(items, 'Delete').getAttribute('aria-describedby')!)!
|
||||||
|
.textContent,
|
||||||
|
).toBe("Built-in roles can't be deleted. Duplicate it to make a custom role.")
|
||||||
|
expect(item(items, 'Edit').getAttribute('aria-disabled')).not.toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables Delete on the locked Owner role', async () => {
|
||||||
|
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||||
|
access: customAccess('roles.view, roles.manage', 'Manager', 9),
|
||||||
|
backend: listBackend(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = await openMenu('Owner')
|
||||||
|
|
||||||
|
expect(item(items, 'Delete').getAttribute('aria-disabled')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables Duplicate and Delete for roles.view only and still offers Edit', async () => {
|
||||||
|
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||||
|
access: customAccess('roles.view', 'Viewer', 9),
|
||||||
|
backend: listBackend(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = await openMenu('Contributor')
|
||||||
|
|
||||||
|
expect(item(items, 'Edit').getAttribute('aria-disabled')).not.toBe('true')
|
||||||
|
expect(item(items, 'Duplicate').getAttribute('aria-disabled')).toBe('true')
|
||||||
|
expect(item(items, 'Delete').getAttribute('aria-disabled')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the editor from Edit and a prefilled editor from Duplicate', async () => {
|
||||||
|
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||||
|
access: adminAccess(),
|
||||||
|
backend: listBackend(),
|
||||||
|
})
|
||||||
|
|
||||||
|
item(await openMenu('Writer'), 'Edit').click()
|
||||||
|
await vi.waitFor(() => expect(router.currentRoute.value.fullPath).toBe('/settings/roles/3'))
|
||||||
|
|
||||||
|
await router.push('/settings/roles')
|
||||||
|
item(await openMenu('Writer'), 'Duplicate').click()
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(router.currentRoute.value.fullPath).toBe('/settings/roles/new?from=3'),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Roles list: Delete', () => {
|
||||||
|
it('opens the blocked dialog for a role with members', async () => {
|
||||||
|
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||||
|
access: ownerAccess(),
|
||||||
|
backend: listBackend(),
|
||||||
|
})
|
||||||
|
|
||||||
|
item(await openMenu('Contributor'), 'Delete').click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(textOf(document.body.querySelector('[role="alertdialog"]')!)).toContain(
|
||||||
|
'4 members have this role. Move them to another role first. Then Contributor can be deleted.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes the row once the role without members is deleted', async () => {
|
||||||
|
const intern = role({ id: 6, name: 'Intern', members: 0 })
|
||||||
|
const backend = listBackend([OWNER, ADMIN, WRITER, intern], {
|
||||||
|
'DELETE /v1/role/6': () => undefined,
|
||||||
|
})
|
||||||
|
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||||
|
access: ownerAccess(),
|
||||||
|
backend,
|
||||||
|
})
|
||||||
|
|
||||||
|
item(await openMenu('Intern'), 'Delete').click()
|
||||||
|
await flushPromises()
|
||||||
|
buttonNamed('Delete role')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(backend.callsTo('DELETE', '/v1/role/6')).toHaveLength(1)
|
||||||
|
expect(backend.callsTo('POST', '/v1/role/6/reassign')).toHaveLength(0)
|
||||||
|
expect(rows().map((row) => textOf(row).split(' ')[0])).not.toContain('Intern')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refreshes the list when the role is already gone', async () => {
|
||||||
|
const intern = role({ id: 6, name: 'Intern', members: 0 })
|
||||||
|
let listed = 0
|
||||||
|
const backend = new Backend({
|
||||||
|
'GET /v1/role/': () =>
|
||||||
|
++listed === 1 ? [OWNER, ADMIN, WRITER, intern] : [OWNER, ADMIN, WRITER],
|
||||||
|
'DELETE /v1/role/6': () => {
|
||||||
|
throw new ApiError(404, undefined, 'role_not_found', { code: 'role_not_found' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||||
|
access: ownerAccess(),
|
||||||
|
backend,
|
||||||
|
})
|
||||||
|
|
||||||
|
item(await openMenu('Intern'), 'Delete').click()
|
||||||
|
await flushPromises()
|
||||||
|
buttonNamed('Delete role')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(backend.callsTo('GET', '/v1/role/')).toHaveLength(2)
|
||||||
|
expect(rows().map((row) => textOf(row).split(' ')[0])).not.toContain('Intern')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { vi } from 'vitest'
|
||||||
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { h } from 'vue'
|
||||||
|
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||||
|
import UApp from '@nuxt/ui/components/App.vue'
|
||||||
|
import router from '@/router'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
import { setViewportWidth, DESKTOP_WIDTH, MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||||
|
import type { Access, Grant } from '@/lib/permissions'
|
||||||
|
import type { PermissionGroupItem, RoleItem } from '@/lib/roles'
|
||||||
|
|
||||||
|
export interface Call {
|
||||||
|
method: string
|
||||||
|
path: string
|
||||||
|
body: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Handler = (call: Call) => unknown
|
||||||
|
|
||||||
|
export class Backend {
|
||||||
|
calls: Call[] = []
|
||||||
|
routes: Record<string, Handler>
|
||||||
|
|
||||||
|
constructor(routes: Record<string, Handler>) {
|
||||||
|
this.routes = routes
|
||||||
|
}
|
||||||
|
|
||||||
|
request = vi.fn(async (path: string, options: RequestInit = {}) => {
|
||||||
|
const method = options.method ?? 'GET'
|
||||||
|
const call: Call = {
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
body: typeof options.body === 'string' ? JSON.parse(options.body) : undefined,
|
||||||
|
}
|
||||||
|
this.calls.push(call)
|
||||||
|
const handler = this.routes[`${method} ${path}`]
|
||||||
|
if (handler === undefined) {
|
||||||
|
throw new Error(`Unmocked ${method} ${path}`)
|
||||||
|
}
|
||||||
|
return handler(call)
|
||||||
|
})
|
||||||
|
|
||||||
|
callsTo(method: string, path: string) {
|
||||||
|
return this.calls.filter((call) => call.method === method && call.path === path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function role(overrides: Partial<RoleItem> & Pick<RoleItem, 'id' | 'name'>): RoleItem {
|
||||||
|
return {
|
||||||
|
description: '',
|
||||||
|
built_in: false,
|
||||||
|
locked: false,
|
||||||
|
version: 1,
|
||||||
|
grants: [],
|
||||||
|
members: 0,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function grants(spec: string): Grant[] {
|
||||||
|
return spec.split(',').map((part) => {
|
||||||
|
const [key, scope = 'all'] = part.trim().split('=')
|
||||||
|
return { key: key!, scope: scope as Grant['scope'] }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CATALOGUE: PermissionGroupItem[] = [
|
||||||
|
{
|
||||||
|
key: 'content',
|
||||||
|
items: ['read', 'create', 'update', 'delete', 'publish'].map((action) => ({
|
||||||
|
key: `content.${action}`,
|
||||||
|
scope_kind: action === 'create' ? ('toggle' as const) : ('scoped' as const),
|
||||||
|
owner_only: false,
|
||||||
|
per_type: true,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'activity',
|
||||||
|
items: [
|
||||||
|
{ key: 'activity.view', scope_kind: 'scoped', owner_only: false, per_type: false },
|
||||||
|
{ key: 'activity.export', scope_kind: 'toggle', owner_only: false, per_type: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'system',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'system.apply_updates',
|
||||||
|
scope_kind: 'toggle',
|
||||||
|
owner_only: true,
|
||||||
|
per_type: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export async function mountRoleView(
|
||||||
|
view: object,
|
||||||
|
path: string,
|
||||||
|
{
|
||||||
|
access,
|
||||||
|
backend,
|
||||||
|
desktop = true,
|
||||||
|
userId = 1,
|
||||||
|
}: { access: Access; backend: Backend; desktop?: boolean; userId?: number },
|
||||||
|
) {
|
||||||
|
setViewportWidth(desktop ? DESKTOP_WIDTH : MOBILE_WIDTH)
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const session = useSessionStore()
|
||||||
|
session.session = { id: userId, verified: true, username: 'marta', displayName: '', access }
|
||||||
|
session.status = 'ready'
|
||||||
|
vi.mocked(useApi).mockReturnValue({
|
||||||
|
getMe: vi.fn().mockResolvedValue({
|
||||||
|
id: userId,
|
||||||
|
verified: true,
|
||||||
|
username: 'marta',
|
||||||
|
displayName: '',
|
||||||
|
access,
|
||||||
|
}),
|
||||||
|
request: backend.request,
|
||||||
|
} as unknown as ReturnType<typeof useApi>)
|
||||||
|
await router.push(path)
|
||||||
|
const wrapper: VueWrapper = mount(() => h(UApp, null, () => h(view)), {
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { plugins: [router, uiPlugin] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
export function textOf(element: Element) {
|
||||||
|
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buttonNamed(label: string, root: ParentNode = document.body) {
|
||||||
|
return Array.from(root.querySelectorAll<HTMLButtonElement>('button')).find(
|
||||||
|
(candidate) => textOf(candidate) === label,
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user