feat: SPA Roles screens

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

No files matched your search

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