chore: roles management groundwork

This commit is contained in:
Florian Sylvain committed 2026-10-26 02:53:33 +01:00
1 parent 7c0ee21906
commit 44cca46d5e
11 files changed
+1255 -5

No files matched your search

+3
View File
@@ -138,3 +138,6 @@ Back-office total 341 (pass 2 groups, table above) + 68 (sites and authors) = 40
- The Components, Handoff and Foundations boards were not updated to mention the new tokens, CodeSnippet or the matrix. - The Components, Handoff and Foundations boards were not updated to mention the new tokens, CodeSnippet or the matrix.
- Integration still lists "max 10000" for article bodies, as the backend does today (change-list item 11). - Integration still lists "max 10000" for article bodies, as the backend does today (change-list item 11).
- The sandbox cannot export; no files were bundled. - The sandbox cannot export; no files were bundled.
## Pass 6: roles and permissions
Board `RenewCMS Screens Roles.dc.html` (frame `RolesFrame`, data `roles-spec.js`): 11 groups, 61 states, all Available now. Spec `screens/roles.md`; additions in components.md, states.md, routes.md, change-list.md. The Permissions grid is folded into the role editor and `/settings/permissions` is retired. Writer frames use AppFrame's existing restricted `user` navigation. AppFrame gained the Access group with a Roles item (sidebar and drawer). Not done: Schema board group 8 (Permissions) should be marked superseded.
+29
View File
@@ -91,3 +91,32 @@ Ordered by implementation dependency: tokens and shell first, then shared compon
| 24 | Articles | Authors column; Site column and filter when more than one site | Yes | **Yes:** `site` filter, `site` and `authors` in the list payload | AuthorStack | No | | 24 | Articles | Authors column; Site column and filter when more than one site | Yes | **Yes:** `site` filter, `site` and `authors` in the list payload | AuthorStack | No |
| 25 | Profile | "Author profile" card in every Profile frame, one-click create, edit own author (user link locked) | Yes | **Yes:** POST /v1/author/me | AuthorProfileCard | No | | 25 | Profile | "Author profile" card in every Profile frame, one-click create, edit own author (user link locked) | Yes | **Yes:** POST /v1/author/me | AuthorProfileCard | No |
| 26 | Backfill | Existing articles get the default site; authors start empty | n/a | **Yes:** migration | None | No | | 26 | Backfill | Existing articles get the default site; authors start empty | n/a | **Yes:** migration | None | No |
## Roles and permissions: screens that change
| Screen | Change |
|---|---|
| Sidebar and mobile drawer (AppFrame) | Items are listed per permission, not per fixed role. Writer sees Home, Articles, Media, Authors, Activity and Content. A Roles entry appears under Settings for people who can view roles. |
| Settings navigation (AppFrame, done) | New "Access" group with a "Roles" item (shield icon) under Settings in the sidebar and the mobile drawer, shown to everyone except the restricted `user` role. "Permissions" is removed. |
| /settings/permissions | Retired. 301 to /settings/roles. The grid lives in the role editor. |
| Users (list, invite, row menu) | Role badges, Change role dialog, role picker in invite (default Writer), pending invites show the role, Transfer ownership, protected owner row. |
| Articles list and Editor | Publish hidden for roles without it; status shows Draft with "Ask an editor to publish." Delete and Edit disabled with a reason when scope is Own and the item is someone else's. |
| Content entry lists and editor | Same Own rules. View only mark. |
| Settings pages | Read-only mode with banner for people who can view but not edit. Owner-only actions disabled with an Owner only tag. |
| Schema | Hidden for roles without Schema view. Read-only for view-only roles. |
| API keys | Info alert "Keys follow their creator's role", a Follows role column. |
| Profile | Your role card and See what I can do. |
| Activity and filters | New event types, role diff in the detail panel. |
| Error and 403 copy | Two codes and the page-level state added. |
Decisions to confirm:
| # | Decision made | Alternative |
|---|---|---|
| 1 | Content permissions come before General permissions in the editor, because the common case (Contributor) only touches the grid. | General first, as in the brief. |
| 2 | Writer gets Authors: view and edit own, and Activity: own. | No Authors or Activity access. |
| 3 | A new role starts with nothing granted; presets fill the grid. | Start from a copy of Writer. |
| 4 | Own-scope rows others created show "View only" with a lock icon, not a hidden row. | Hide other people's entries. |
| 5 | Reset to default exists for built-in roles edited by the owner. | No reset. |
| 6 | Activity own/all scope uses the same None / Own / All control. | Toggle plus a separate scope select. |
| 7 | Transfer ownership takes effect at once, no email to the new owner. | The new owner must accept. |
| 8 | A user with Roles: manage cannot edit their own role, even in a custom role. | Allow narrowing only. |
+26
View File
@@ -131,3 +131,29 @@ Frames: `SchemaFrame.dc.html` (`MCtl`) and `MediaFrame.dc.html` (picker in field
| MediaFieldControl | New | UFormField, UButton outline and ghost, UModal (desktop) or bottom UDrawer (mobile) for the picker, UTooltip, UIcon (image, image-off, unlink, image-plus). The thumbnail is a plain button | Props label, required, modelValue (image id or null), image (filename, width, height, size, or missing), disabled, error. Emits update:modelValue. States: empty, filled, hover, focus, error, missing, saving, read-only | | MediaFieldControl | New | UFormField, UButton outline and ghost, UModal (desktop) or bottom UDrawer (mobile) for the picker, UTooltip, UIcon (image, image-off, unlink, image-plus). The thumbnail is a plain button | Props label, required, modelValue (image id or null), image (filename, width, height, size, or missing), disabled, error. Emits update:modelValue. States: empty, filled, hover, focus, error, missing, saving, read-only |
| FieldCell (media) | Extended | Thumbnail 40 px square, dash when empty, dashed square with unlink when the image is gone | Row height 48 px, mobile card thumbnail 32 px | | FieldCell (media) | Extended | Thumbnail 40 px square, dash when empty, dashed square with unlink when the image is gone | Row height 48 px, mobile card thumbnail 32 px |
| MediaPicker (field mode) | Extended | Existing picker | Title "Choose an image", "Use this image", no alt text, no variant select, "Current" badge, optional "No image" row | | MediaPicker (field mode) | Extended | Existing picker | Title "Choose an image", "Use this image", no alt text, no variant select, "Current" badge, optional "No image" row |
## Roles and permissions (new)
| Component | Used for | Nuxt UI mapping |
|---|---|---|
| ScopeControl (tri-state) | None / Own / All in grid cells, mobile cards and general rows | URadioGroup variant="table" orientation="horizontal" size="xs", three items, in a grid cell. Selected item: sand fill, weight 600 and an underline |
| OnOffCell | Create column and boolean rows | USwitch with visible On/Off label |
| PermissionGrid | Content permissions | Custom grid (role="grid") of ScopeControl cells; header buttons use UDropdownMenu for bulk actions |
| PresetPills | Read only, Write own, Full content | UButton variant="outline" size="xs", pressed state via aria-pressed |
| PermissionGroup | Collapsible group with "x of y enabled" | UCollapsible; header UButton variant="ghost" |
| OwnerOnlyTag | Locked owner-only items | UBadge variant="subtle" with lucide lock icon |
| RoleBadge | Users, roles, API keys | UBadge variant="outline" with role icon (crown, shield, pencil, user-round) |
| RolePreview | What this role can do | Card on desktop (UCard), USlideover side="bottom" on mobile |
| RolePicker | Change role, invite, reassign on delete | URadioGroup variant="card" with description; USelectMenu for the reassign field; items disabled with a reason in description |
| SaveBar | Unsaved changes | Sticky container with UButton color="neutral" variant="outline" and UButton color="primary" |
| ReadOnlyBanner | Read-only pages | UAlert color="info" icon="i-lucide-lock" variant="soft" |
| ViewOnlyMark | Own scope rows | Text with lucide lock icon and UTooltip |
Accessibility of the tri-state cell:
| Item | Pattern |
|---|---|
| Structure | The grid is role="grid". Each cell contains a role="radiogroup" named "{Type}: {Action}" (for example "Events: Update") with three role="radio" items None, Own, All. Create is a role="switch" named "Events: Create". |
| Keyboard | Tab moves between cells (one stop per cell). Inside a group, Left and Right (or Up and Down) move and select, Home and End jump to None and All. Arrow keys also move the roving tabindex. Space selects. Header bulk buttons open a menu with the same three values. |
| Announcement | Selected item is aria-checked="true". A polite live region announces changes: "Events, Update, set to Own." Disabled items are aria-disabled and described by the reason ("You can only grant what your role has."). |
| Not by color alone | Selected segment: sand fill plus weight 600 plus a 2 px underline in --ui-primary. Locked: lock icon. Changed cell: square marker. Mixed: text badge. |
| Targets | 26 px segments on desktop, 44 px on mobile. Focus: 2 px --ui-primary outline, offset 2 px. |
+22 -1
View File
@@ -22,7 +22,7 @@
| /content/:key | Entry list (any verified user). Available now | page, size, sort | GET /v1/content-type/{key}, GET /v1/content-type/{key}/entry?page=&size=&sort= | | /content/:key | Entry list (any verified user). Available now | page, size, sort | GET /v1/content-type/{key}, GET /v1/content-type/{key}/entry?page=&size=&sort= |
| /content/:key/new | New entry (any verified user). Available now | none | POST /v1/content-type/{key}/entry | | /content/:key/new | New entry (any verified user). Available now | none | POST /v1/content-type/{key}/entry |
| /content/:key/:id | Entry editor (any verified user). Available now | none | GET/PUT/DELETE /v1/content-type/{key}/entry/{id} | | /content/:key/:id | Entry editor (any verified user). Available now | none | GET/PUT/DELETE /v1/content-type/{key}/entry/{id} |
| /settings/permissions | Permissions grid (owner or admin). Later | none | not built | | /settings/permissions | Retired. 301 to /settings/roles (see Roles and permissions below) | none | not built |
## Pagination in the URL (Articles, Media) ## Pagination in the URL (Articles, Media)
`/articles?page=2&size=50` and `/media?page=7&size=50`. Parameter names follow the repo (activity already uses `page` and `size`). Allowed sizes 5, 25, 50, 100. Defaults `page=1`, `size=25`, omitted from the URL when default. Changing search, tab, filter or size resets `page` to 1. `page` above the last page redirects (replace) to the last page and shows "That page doesn't exist. Showing the last page." An invalid `size` falls back to 25. `/articles?page=2&size=50` and `/media?page=7&size=50`. Parameter names follow the repo (activity already uses `page` and `size`). Allowed sizes 5, 25, 50, 100. Defaults `page=1`, `size=25`, omitted from the URL when default. Changing search, tab, filter or size resets `page` to 1. `page` above the last page redirects (replace) to the last page and shows "That page doesn't exist. Showing the last page." An invalid `size` falls back to 25.
@@ -53,3 +53,24 @@ No new page routes. The panel and dialog are overlays on `/article/:id/edit`: `?
## Custom content types in the URL ## Custom content types in the URL
`/content/events?page=2&size=50&sort=created_at:desc`. `page` and `size` follow Articles (sizes 5, 25, 50, 100; default 25). `sort` is one parameter, `<id|created_at|updated_at>:<asc|desc>`, default `updated_at:desc`. Defaults are omitted from the URL; a size or sort change resets `page` to 1; invalid values are replaced silently by the default; a `page` past the end falls back to the last page. The response carries `total`, `page`, `size` and `sort`: use them for the summary ("Showing 1–5 of 37"), the pager and the "37 entries use this content type" line of the review step. No filter or search on custom fields yet. Auth: `/schema*` owner or admin (403 page otherwise), `/content*` any verified user (unverified accounts are redirected to /register/pending). Unknown type: 404 content_type_not_found page. Unknown entry: 404 entry_not_found page. `/content/events?page=2&size=50&sort=created_at:desc`. `page` and `size` follow Articles (sizes 5, 25, 50, 100; default 25). `sort` is one parameter, `<id|created_at|updated_at>:<asc|desc>`, default `updated_at:desc`. Defaults are omitted from the URL; a size or sort change resets `page` to 1; invalid values are replaced silently by the default; a `page` past the end falls back to the last page. The response carries `total`, `page`, `size` and `sort`: use them for the summary ("Showing 1–5 of 37"), the pager and the "37 entries use this content type" line of the review step. No filter or search on custom fields yet. Auth: `/schema*` owner or admin (403 page otherwise), `/content*` any verified user (unverified accounts are redirected to /register/pending). Unknown type: 404 content_type_not_found page. Unknown entry: 404 entry_not_found page.
## Roles and permissions
| Route | Purpose | Access |
|---|---|---|
| /settings/roles | Roles list | Roles: view |
| /settings/roles/new | New role | Roles: manage |
| /settings/roles/:id | Role editor (read-only without manage) | Roles: view |
| /settings/permissions | Retired. 301 to /settings/roles | n/a |
API: GET/POST /v1/role · GET/PUT/DELETE /v1/role/{id} · POST /v1/role/{id}/reassign · PUT /v1/user/{id}/role · POST /v1/site/transfer-ownership · GET /v1/me · GET /v1/permission. Errors: 403 missing_permission, 403 role_locked, 409 role_has_members, 409 role_name_taken, 409 role_conflict.
Needs backend:
| Item | Detail |
|---|---|
| Role storage | Table role (id, name, description, built_in, locked, version) and role_permission (role_id, key, scope none|own|all). One role_id on user. Built-ins seeded and non-deletable. |
| Endpoints | GET/POST /v1/role · GET/PUT/DELETE /v1/role/{id} · POST /v1/role/{id}/reassign · PUT /v1/user/{id}/role · POST /v1/site/transfer-ownership · GET /v1/me (role and permissions) · GET /v1/permission (catalogue) |
| created_by | Add created_by (user id) to articles and entries so Own scope can be checked on read, update, delete and publish. Backfill from existing author or activity data. |
| Enforcement | Server checks every call against role permissions and the no-escalation rules. API keys resolve the creator's current role on each request. |
| Version field | role.version for the conflict state (409 role_conflict). |
| Activity events | role.created, role.updated (with a diff of added and removed permissions), role.deleted, user.role_changed, site.ownership_transferred. |
+565
View File
@@ -0,0 +1,565 @@
# Roles and permissions
Routes: `/settings/roles`, `/settings/roles/new`, `/settings/roles/:id`. `/settings/permissions` is retired and redirects (301) to `/settings/roles`. Board: `RenewCMS Screens Roles.dc.html` (frame `RolesFrame`). Every state is "Available now". Copy below is exact; `·` separates strings.
## 1 Roles list
Route: /settings/roles · API: GET /v1/role · Auth: Roles: view
Settings > Roles. Visible to anyone who can view roles. Owner carries a Locked badge. The row menu has Edit, Duplicate and Delete. Mobile: one card per role.
### Default (Available now)
Trigger: Owner opens /settings/roles.
Copy: Roles · Who can do what on this site. Each user has one role. · New role · Role · Description · Members · Type · Owner · Locked · Admin · Writer · Contributor · Content lead · Built-in · Custom
### Loading (Available now)
Trigger: First load or retry. Skeleton rows after 150 ms. New role disabled.
Copy: (skeleton) · New role (disabled)
### Only built-in roles (Available now)
Trigger: GET /v1/role returns the three built-ins and no custom role.
Copy: No custom roles yet · The three built-in roles cover most sites. Create a role when someone needs something in between. · New role
### Row menu: custom role (Available now)
Trigger: Open the row menu on Contributor.
Copy: Edit · Duplicate · Delete
### Row menu: built-in role (Available now)
Trigger: Open the row menu on Writer as owner. Delete is disabled and its tooltip shows on focus or hover.
Copy: Edit · Duplicate · Delete (disabled) · Built-in roles can't be deleted. Duplicate it to make a custom role.
### No permission to edit (Available now)
Trigger: User has Roles: view but not Roles: manage. New role is disabled with a tooltip.
Copy: You can see roles but not change them · Ask an owner or admin if a role needs to change. · You can see roles, but only people who manage roles can create or change them.
### Error (Available now)
Trigger: 500 or network failure.
Copy: Couldn't load roles · GET /v1/role failed. Check your connection and try again. · Try again
## 2 Role editor
Route: /settings/roles/new, /settings/roles/:id · API: POST /v1/role, GET/PUT /v1/role/{id} · Auth: Roles: manage (view for read-only)
Name and description, then Content permissions (the grid moved here from /settings/permissions) and General permissions. Desktop: the plain-language panel sits on the right. Mobile: the grid is one card per type, and a sticky bar holds "What this role can do" and Save. The old route /settings/permissions redirects here (301 to /settings/roles).
### New role (empty) (Available now)
Trigger: Owner or admin opens /settings/roles/new. Nothing is granted. Save is off until the role has a name.
Copy: New role · Name · For example, Contributor · Description (optional) · What this role is for · Name your role to save it · Discard · Save changes · Name Contributor · Description Creates content and edits only their own. · Content permissions · What this role can do with entries of each content type. Own means entries the person created. · Quick presets · Read only · Write own · Full content · Content type · Read · Create · Update · Delete · Publish · All content types · Sets every row · Articles · Built-in · Schema locked · None · Own · All · On · Off · General permissions · Everything outside entries. · Expand all
### Editing a custom role (Available now)
Trigger: Open Contributor. Two groups open. Case studies shows Custom with a Reset link.
Copy: Name Contributor · Description Creates content and edits only their own. · Content permissions · What this role can do with entries of each content type. Own means entries the person created. · Quick presets · Read only · Write own · Full content · Content type · Read · Create · Update · Delete · Publish · All content types · Sets every row · Articles · Built-in · Schema locked · None · Own · All · On · Off · General permissions · Everything outside entries. · Expand all · Custom · Reset · 4 members · Custom
### Built-in role, as owner (Available now)
Trigger: The owner opens Writer. A warning shows how many members are affected.
Copy: 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. · 2 unsaved changes
### Built-in role, as admin (read-only) (Available now)
Trigger: An admin opens Writer. All controls are disabled and the fields are locked.
Copy: Only the owner can edit built-in roles · You can read what Writer allows. To change it, duplicate it into a custom role. · Duplicate role · Built-in
### Owner role (locked) (Available now)
Trigger: Anyone with Roles: view opens Owner.
Copy: The Owner role is locked · It always has every permission. To hand it to someone else, transfer ownership from Users. · Go to Users · Locked
### General permissions (Available now)
Trigger: Scrolled to General permissions. Groups collapse; each shows "x of y enabled". Scoped rows use the same None / Own / All control.
Copy: Activity · See activity · Read the log of who did what. · Export activity · Download the log as a CSV file. · Users and invites · View users · Invite users · Change a user's role · Remove users · Settings · View settings · Edit general settings · Manage sites · See API keys · Create API keys · Revoke API keys · Delete a site · Owner only · 2 of 2 enabled
### Owner-only items (Available now)
Trigger: Roles and System groups, as an admin. Owner-only items are disabled with a lock and an Owner only tag. They stay off even for Admin.
Copy: Roles · View roles · See roles and what they allow. · Manage roles · Create, edit, duplicate and delete roles. · Edit roles with owner-only power · Change a role that carries owner-only abilities. · System · Apply software updates · Install a new version of RenewCMS. · Edit activity retention · How long the activity log is kept. · Transfer ownership · Make another member the owner. · Owner only
### Unsaved changes (Available now)
Trigger: Three edits. Changed cells carry a square marker and the preview tags the changed line.
Copy: 3 unsaved changes · Discard · Save changes · Changed
### Saving (Available now)
Trigger: PUT in flight. Fields dim; Save shows the loader.
Copy: Saving… · Discard (disabled)
### Saved (Available now)
Trigger: 200. The bar closes and a toast confirms.
Copy: Role saved · Contributor is updated for 4 members.
### Name required (Available now)
Trigger: Save with an empty name.
Copy: Give the role a name. · 1 problem to fix
### Name already taken (Available now)
Trigger: 409 role_name_taken on POST or PUT.
Copy: A role named "Writer" already exists. Choose a different name. · 1 problem to fix
### Escalation blocked (Available now)
Trigger: The editor holds Roles: manage but can publish only their own entries, so Publish: All is disabled with a lock.
Copy: You can only grant what your role has. Your role can publish only entries you created, so All isn't available. · All (locked)
### Conflict (Available now)
Trigger: 409 role_conflict on PUT (version mismatch).
Copy: 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. · Reload role · Review differences · 2 unsaved changes. Save is off until you reload
### Permission lowered while open (403) (Available now)
Trigger: 403 missing_permission on PUT or on refresh. The page becomes read-only.
Copy: 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. · Reload page
## 3 Plain-language preview
Route: Panel inside the role editor; bottom sheet on mobile · API: None. Computed in the browser from the form · Auth: Roles: view
Title "What this role can do". Two parts: Content (one sentence per group of types that share the same permissions) and Everything else (one sentence per group). A last line lists areas with no access. Lines that change while editing carry a Changed tag. Sentences are built from fixed templates, never from keys.
### Live panel (Available now)
Trigger: Editing Contributor. Events: Update changed from Own to All. The first line is tagged.
Copy: What this role can do · Updates as you edit. · Content · All content types · Can read any entry, create entries and edit only their own. Can't delete and publish. · Everything else · 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. · No access to Activity, Users and invites, Settings, Roles. · Applies to 4 members, and to the API keys they created. · Changed
### Read-only summary (Available now)
Trigger: Opened from a built-in role in read-only mode. No "Updates as you edit".
Copy: What this role can do · A summary of what this role allows.
### Nothing granted (Available now)
Trigger: New role with every permission off.
Copy: Content · All content types · No access to entries. · No access to Schema, Media, Authors, Activity, Users and invites, Settings, Roles.
### Mobile sheet (Available now)
Trigger: Tap "What this role can do" in the sticky bar. 92% sheet with grabber and close.
Copy: What this role can do · Updates as you edit. · Close
## 4 Delete role
Route: Dialog over /settings/roles · API: POST /v1/role/{id}/reassign, DELETE /v1/role/{id} · Auth: Roles: manage
A role with members is blocked until they move. Choosing a target role enables a single action that moves them and deletes the role. Roles with more power than the user are disabled in the picker with a reason.
### Blocked: has members (Available now)
Trigger: Delete Contributor, 4 members.
Copy: Delete Contributor? · 4 members have this role. Move them to another role first. Then Contributor can be deleted. · Move members to · Choose a role · They keep their API keys. Keys follow the new role. · Cancel · Move 4 members and delete role (disabled until a role is chosen)
### Role picker open (Available now)
Trigger: The picker shows each role's summary. Admin is disabled for an admin.
Copy: Writer · Reads everything and writes content. Can't delete or publish. · Content lead · Edits and publishes all content. · Admin · Your role can't assign a role with more power than yours.
### Confirm: no members (Available now)
Trigger: Delete Intern, 0 members.
Copy: Delete Intern? · No one has this role. This can't be undone. · Deleting a role doesn't affect content. · Cancel · Delete role
### Built-in role (Available now)
Trigger: Delete is disabled in the menu. If the dialog is reached by link it shows this state.
Copy: 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. · Duplicate role · Close
## 5 Users: roles and ownership
Route: /users · API: PUT /v1/user/{id}/role, POST /v1/invite, POST /v1/site/transfer-ownership · Auth: Users: view; change role, invite, remove; owner for transfer
The Role column shows a role badge with an icon: crown (Owner), shield (Admin), pencil (Writer), person (custom). The owner row is protected. Pending invites show their role.
### List with roles (Available now)
Trigger: Owner opens /users.
Copy: Users · 5 members and invites · Invite user · Name · Email · Role · Status · Owner · Admin · Writer · Contributor · Content lead · Active · Invite pending
### Row menu (Available now)
Trigger: Open the menu on Ines Duarte.
Copy: Change role · Send password reset · Remove user
### Owner row menu (Available now)
Trigger: Owner row: Change role and Remove are disabled; Transfer ownership appears.
Copy: Change role (disabled) · Transfer ownership · Remove user (disabled) · The owner can't be changed or removed. Transfer ownership first.
### Change role dialog (Available now)
Trigger: Each option shows the role's one-line summary. The current role is marked.
Copy: Change role for Ines Duarte · Her API keys follow the new role. · Admin · Writer · · Current · Contributor · Content lead · Cancel · Change role
### Change role: options blocked (Available now)
Trigger: An admin opens the dialog. Roles with more power are disabled with a reason.
Copy: Owner · There is one owner. Transfer ownership to change who it is. · Content lead · It can publish. Your role can't grant that.
### Invite dialog (Available now)
Trigger: Role picker defaults to Writer and shows its summary.
Copy: Invite user · They get an email with a link to set a password. · Email · Role · Writer · Writer is the default. You can change a role later. · Cancel · Send invite
### Transfer ownership: pick member (Available now)
Trigger: Owner menu > Transfer ownership. Only members can be chosen.
Copy: Transfer ownership · Choose who becomes the owner. Only members can be chosen. · Cancel · Continue
### Transfer ownership: confirm (Available now)
Trigger: Consequences listed. The action stays off until the email is retyped exactly.
Copy: Transfer ownership to Jonas Weber · This takes effect straight away. · Jonas Weber becomes the owner. · You become an Admin. You can't take ownership back unless he transfers it. · Only the owner can apply updates, delete a site and change activity retention. · Your API keys follow your new role. · Type jonas@renewbuild.example to confirm · The email must match exactly. · Back · Transfer ownership
### Transfer ownership: done (Available now)
Trigger: The former owner is now Admin. The list shows the new owner.
Copy: Ownership transferred · Jonas Weber is now the owner. You are an Admin.
## 6 Role-aware shell
Route: Every page · API: GET /v1/me returns role and permission keys · Auth: Any
Rule: hide whole areas the person can never use; disable, with a reason, an action that is blocked on something they can see; show read-only pages with a banner. Writer in these frames is the built-in Writer; Contributor shows "own" scope.
### The rule (Available now)
Trigger: Reference card used across the app.
Copy: Hidden · The person can never use it. · Disabled, with a reason · The person sees the thing but can't act on it. · Read-only · The person may look but not change.
### Writer: Articles list (Available now)
Trigger: Create and edit all, no delete, no publish. No Schema, Users, Integration or Settings in the sidebar.
Copy: Articles · You can write and edit all articles. Publishing is done by an editor. · New article · Created by · Status · Draft · Published
### Writer: row menu (Available now)
Trigger: Delete is disabled with a reason.
Copy: Edit · Duplicate · Delete (disabled) · Your role can't delete articles.
### Writer: entry editor (Available now)
Trigger: Publish is hidden. Status reads Draft with a hint.
Copy: Save draft · Status · Draft · Saved at 11:02 · Ask an editor to publish.
### Own scope: entry list (Available now)
Trigger: Contributor can edit only entries they created. Others show a View only mark.
Copy: Events · You can create entries and edit your own. · Entries by others are view only · Your role lets you edit entries you created. · New entry · View only
### Own scope: row menu (Available now)
Trigger: On an entry by someone else, Edit and Delete are disabled.
Copy: Edit (disabled) · Duplicate · Delete (disabled) · You can only edit content you created.
### Admin: read-only settings (Available now)
Trigger: An admin without Edit general settings. Fields locked, Save off.
Copy: You can read these settings but not change them · Ask the owner to change them, or ask for a role that can edit settings. · Save changes (disabled)
### Admin: owner-only settings (Available now)
Trigger: Software and Retention show an Owner only tag. The action stays visible and disabled.
Copy: Owner only · Apply update (disabled) · Only the owner can apply updates. · Keep activity for · Admins can read the log. Only the owner can change how long it is kept.
## 7 No access: 403 copy
Route: Any page · API: 403 missing_permission, 403 role_locked · Auth: Any
A direct link to a page the person can't use shows the page-level state with a link to a page they can use. A blocked action shows a toast.
### Page: no access (Available now)
Trigger: Contributor opens /settings/roles by link.
Copy: You don't have access to this page · Your role, Contributor, doesn't include it. Ask an owner or admin if you need it. · Go to Articles · See what I can do
### Action: missing_permission (Available now)
Trigger: An action was refused with 403 missing_permission.
Copy: You don't have permission to do that · Your role doesn't include Delete entries. Ask an owner or admin.
### Action: role_locked (Available now)
Trigger: An attempt to edit or delete the Owner role.
Copy: This role is locked · The Owner role can't be changed or deleted. Transfer ownership from Users instead.
## 8 Activity: role events
Route: /activity · API: GET /v1/activity · Auth: Activity: own or all
New event types. The detail panel summarises a role diff as Added and Removed lines using the human permission names.
### List with role events (Available now)
Trigger: Five new events.
Copy: Role Contributor created · Role Contributor edited · Ines Duarte's role changed from Writer to Content lead · Ownership transferred from Marta Alves to Jonas Weber · Role Intern deleted
### Detail: role diff (Available now)
Trigger: Open "Role Contributor edited".
Copy: Activity details · What changed · Added · Delete entries (Own) · Removed · Publish · 4 members have this role.
## 9 Profile: your role
Route: /profile · API: GET /v1/me · Auth: Any
The person sees their own role and a read-only summary.
### Role card (Available now)
Trigger: Any signed-in user.
Copy: Your role · Contributor · Assigned by Marta Alves · Creates content and edits only their own. · See what I can do · Your API keys follow this role. If your role changes, they change too.
### See what I can do (Available now)
Trigger: Opens the summary. Dialog on desktop, sheet on mobile.
Copy: What you can do · A summary of your role, Contributor. Read-only. · Close
## 10 API keys: inherited role
Route: /settings/api-keys · API: GET /v1/api-key · Auth: Settings: API keys
Keys follow their creator's role live. The fact is stated in an info alert and each row shows the creator's role.
### Keys follow the role (Available now)
Trigger: Always shown on the API keys screen.
Copy: Keys follow their creator's role · A key can do what its creator's role can do, today. If the person's role changes, their keys change with it. · Follows role
## 11 Flow: create "Contributor" in under a minute
Route: /settings/roles, /settings/roles/new · API: POST /v1/role · Auth: Roles: manage
Six steps, about 40 seconds: 1 New role. 2 Name and description. 3 Choose the preset Write own. 4 Check the preview and the Schema and Media defaults. 5 Save. 6 Done. A new role starts with nothing granted; presets fill the grid in one tap.
### 1 Roles list: New role (Available now)
Trigger: Click New role.
Copy: New role
### 2 Name it (Available now)
Trigger: Type a name and a one-line description.
Copy: Name Contributor · Description Writes posts and edits only their own
### 3 Tap the preset: Write own (Available now)
Trigger: The preset sets Read: All, Create: On, Update: Own, Delete and Publish: None for every type.
Copy: Write own · 7 unsaved changes
### 4 Check General permissions (Available now)
Trigger: Optional. Schema view and Media view and upload are on by default for the preset. Check the preview.
Copy: What this role can do · Can read any entry, create entries and edit only their own. Can't delete and publish.
### 5 Save (Available now)
Trigger: Save changes. The role appears in the list and in the invite picker.
Copy: Save changes
### 6 Done (Available now)
Trigger: Toast, and the bar closes.
Copy: Role created · Contributor is ready. Assign it from Users.
## Tables
The board lists, and this file repeats, the tables below.
### Permission catalogue (names shown to people)
| Group | Items (human name) | Scope |
|---|---|---|
| Content, per type | Read entries · Create entries · Update entries · Delete entries · Publish entries | None / Own / All, except Create (on or off) |
| Schema | View content types · Create and edit content types · Delete content types | On or off |
| Media | View the media library · Upload files · Delete files | Delete: None / Own / All |
| Authors | View authors · Create authors · Edit authors · Delete authors | Edit: None / Own / All |
| Activity | See activity · Export activity | See: None / Own / All |
| Users and invites | View users · Invite users · Change a user's role · Remove users | On or off |
| Settings | View settings · Edit general settings · Manage sites · See API keys · Create API keys · Revoke API keys · Delete a site (owner only) | API keys: None / Own / All |
| Roles | View roles · Manage roles · Edit roles with owner-only power (owner only) | On or off |
| System (owner only) | Apply software updates · Edit activity retention · Transfer ownership | Locked off for everyone but the owner |
### Built-in roles
| Role | What it has | Editable by |
|---|---|---|
| Owner | Everything. One per site. Ownership can be transferred; the former owner becomes Admin. | No one (locked) |
| Admin | Everything except the owner-only list. Can see all settings, read-only where they can't edit. | Owner |
| Writer | Reads schema and all content; creates and edits all content. No delete, no publish; no schema, settings, sites, users or integrations. Authors: view, edit own (assumed). | Owner (duplicate for variants) |
### No-escalation feedback
| Rule | Where it shows | Copy |
|---|---|---|
| Grant only what you hold | Disabled segment or switch with a lock; popover on focus or hover | You can only grant what your role has. Your role can publish only entries you created, so All isn't available. |
| No role above yours | Disabled option in the role picker (Change role, Delete reassign, Invite) | Your role can't assign a role with more power than yours. |
| No editing your own role | Editor opens read-only with a banner | You can't change the permissions of your own role. Ask another owner or admin. |
| No delete while it has members | Delete dialog, blocked state | 4 members have this role. Move them to another role first. Then Contributor can be deleted. |
| Owner-only list | Disabled item with an Owner only tag and a lock | Only the owner can apply updates. |
### Hidden or disabled
| Case | Treatment | Example |
|---|---|---|
| Whole area the role never has | Hidden (no sidebar item, no page card). Direct link shows the 403 page. | Schema for a Writer |
| Action on something visible, blocked by role | Disabled with a reason in a tooltip or row text | Delete on an article for a Writer |
| Action that only exists above their level | Hidden | Publish for a Writer; status shows Draft and "Ask an editor to publish." |
| Row the person can read but not edit (Own) | View only mark on the row; Edit and Delete disabled in the menu | You can only edit content you created. |
| Settings page they can read | Read-only: locked fields, banner, Save off | Admin on General without Edit general settings |
| Owner-only action visible to an admin | Disabled, Owner only tag | Apply update |
### HTTP status to copy (additions)
| Code | Status | Title | Body |
|---|---|---|---|
| missing_permission | 403 | You don't have permission to do that | Your role doesn't include {permission name}. Ask an owner or admin. |
| role_locked | 403 | This role is locked | The Owner role can't be changed or deleted. Transfer ownership from Users instead. |
| role_has_members | 409 | Role still has members | {n} members have this role. Move them to another role first. |
| role_name_taken | 409 | Name already used | A role named "{name}" already exists. Choose a different name. |
| role_conflict | 409 | This role changed while you were editing | {name} saved changes to {role} at {time}. Reload to see them, or review how they differ from your edits. |
| escalation | 403 | You can't grant that | You can only grant what your role has. |
| (page level) | 403 | You don't have access to this page | Your role, {role}, doesn't include it. Ask an owner or admin if you need it. |
### Components (new) and Nuxt UI v4 mapping
| Component | Used for | Nuxt UI mapping |
|---|---|---|
| ScopeControl (tri-state) | None / Own / All in grid cells, mobile cards and general rows | URadioGroup variant="table" orientation="horizontal" size="xs", three items, in a grid cell. Selected item: sand fill, weight 600 and an underline |
| OnOffCell | Create column and boolean rows | USwitch with visible On/Off label |
| PermissionGrid | Content permissions | Custom grid (role="grid") of ScopeControl cells; header buttons use UDropdownMenu for bulk actions |
| PresetPills | Read only, Write own, Full content | UButton variant="outline" size="xs", pressed state via aria-pressed |
| PermissionGroup | Collapsible group with "x of y enabled" | UCollapsible; header UButton variant="ghost" |
| OwnerOnlyTag | Locked owner-only items | UBadge variant="subtle" with lucide lock icon |
| RoleBadge | Users, roles, API keys | UBadge variant="outline" with role icon (crown, shield, pencil, user-round) |
| RolePreview | What this role can do | Card on desktop (UCard), USlideover side="bottom" on mobile |
| RolePicker | Change role, invite, reassign on delete | URadioGroup variant="card" with description; USelectMenu for the reassign field; items disabled with a reason in description |
| SaveBar | Unsaved changes | Sticky container with UButton color="neutral" variant="outline" and UButton color="primary" |
| ReadOnlyBanner | Read-only pages | UAlert color="info" icon="i-lucide-lock" variant="soft" |
| ViewOnlyMark | Own scope rows | Text with lucide lock icon and UTooltip |
### Accessibility: tri-state cell
| Item | Pattern |
|---|---|
| Structure | The grid is role="grid". Each cell contains a role="radiogroup" named "{Type}: {Action}" (for example "Events: Update") with three role="radio" items None, Own, All. Create is a role="switch" named "Events: Create". |
| Keyboard | Tab moves between cells (one stop per cell). Inside a group, Left and Right (or Up and Down) move and select, Home and End jump to None and All. Arrow keys also move the roving tabindex. Space selects. Header bulk buttons open a menu with the same three values. |
| Announcement | Selected item is aria-checked="true". A polite live region announces changes: "Events, Update, set to Own." Disabled items are aria-disabled and described by the reason ("You can only grant what your role has."). |
| Not by color alone | Selected segment: sand fill plus weight 600 plus a 2 px underline in --ui-primary. Locked: lock icon. Changed cell: square marker. Mixed: text badge. |
| Targets | 26 px segments on desktop, 44 px on mobile. Focus: 2 px --ui-primary outline, offset 2 px. |
### Needs backend
| Item | Detail |
|---|---|
| Role storage | Table role (id, name, description, built_in, locked, version) and role_permission (role_id, key, scope none|own|all). One role_id on user. Built-ins seeded and non-deletable. |
| Endpoints | GET/POST /v1/role · GET/PUT/DELETE /v1/role/{id} · POST /v1/role/{id}/reassign · PUT /v1/user/{id}/role · POST /v1/site/transfer-ownership · GET /v1/me (role and permissions) · GET /v1/permission (catalogue) |
| created_by | Add created_by (user id) to articles and entries so Own scope can be checked on read, update, delete and publish. Backfill from existing author or activity data. |
| Enforcement | Server checks every call against role permissions and the no-escalation rules. API keys resolve the creator's current role on each request. |
| Version field | role.version for the conflict state (409 role_conflict). |
| Activity events | role.created, role.updated (with a diff of added and removed permissions), role.deleted, user.role_changed, site.ownership_transferred. |
### Existing screens that change
| Screen | Change |
|---|---|
| Sidebar and mobile drawer (AppFrame) | Items are listed per permission, not per fixed role. Writer sees Home, Articles, Media, Authors, Activity and Content. A Roles entry appears under Settings for people who can view roles. |
| Settings navigation | New "Roles" item. "Permissions" is removed. |
| /settings/permissions | Retired. 301 to /settings/roles. The grid lives in the role editor. |
| Users (list, invite, row menu) | Role badges, Change role dialog, role picker in invite (default Writer), pending invites show the role, Transfer ownership, protected owner row. |
| Articles list and Editor | Publish hidden for roles without it; status shows Draft with "Ask an editor to publish." Delete and Edit disabled with a reason when scope is Own and the item is someone else's. |
| Content entry lists and editor | Same Own rules. View only mark. |
| Settings pages | Read-only mode with banner for people who can view but not edit. Owner-only actions disabled with an Owner only tag. |
| Schema | Hidden for roles without Schema view. Read-only for view-only roles. |
| API keys | Info alert "Keys follow their creator's role", a Follows role column. |
| Profile | Your role card and See what I can do. |
| Activity and filters | New event types, role diff in the detail panel. |
| Error and 403 copy | Two codes and the page-level state added. |
### Decisions to confirm
| # | Decision made | Alternative |
|---|---|---|
| 1 | Content permissions come before General permissions in the editor, because the common case (Contributor) only touches the grid. | General first, as in the brief. |
| 2 | Writer gets Authors: view and edit own, and Activity: own. | No Authors or Activity access. |
| 3 | A new role starts with nothing granted; presets fill the grid. | Start from a copy of Writer. |
| 4 | Own-scope rows others created show "View only" with a lock icon, not a hidden row. | Hide other people's entries. |
| 5 | Reset to default exists for built-in roles edited by the owner. | No reset. |
| 6 | Activity own/all scope uses the same None / Own / All control. | Toggle plus a separate scope select. |
| 7 | Transfer ownership takes effect at once, no email to the new owner. | The new owner must accept. |
| 8 | A user with Roles: manage cannot edit their own role, even in a custom role. | Allow narrowing only. |
State count: 61.
+1 -1
View File
@@ -18,7 +18,7 @@ All later. The model reads as two sentences, with a live preview of both sides.
### 8 Permissions grid (group "8 Permissions", 2 states) ### 8 Permissions grid (group "8 Permissions", 2 states)
Route: `/settings/permissions`. API: not built. Auth: Owner or admin. Route: `/settings/permissions`, retired: the grid now lives in the role editor (`/settings/roles/:id`), see `roles.md`. Kept below as the original draft.
All later. Rows are types, columns are actions, one role at a time. Built-in rows are locked. Mobile collapses to one card per type with four checkboxes. All later. Rows are types, columns are actions, one role at a time. Built-in rows are locked. Mobile collapses to one card per type with four checkboxes.
+3 -3
View File
@@ -12,7 +12,7 @@
<div style="width:224px;flex-shrink:0;background:var(--ui-bg);border-right:1px solid var(--ui-border);display:flex;flex-direction:column;padding:12px;gap:2px"> <div style="width:224px;flex-shrink:0;background:var(--ui-bg);border-right:1px solid var(--ui-border);display:flex;flex-direction:column;padding:12px;gap:2px">
<div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">RenewCMS</span></div> <div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">RenewCMS</span></div>
<sc-for list="{{ nav }}" as="n" hint-placeholder-count="3"> <sc-for list="{{ nav }}" as="n" hint-placeholder-count="3">
<div data-act="nav-{{ n.key }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat"></span>{{ n.t }}<sc-if value="{{ n.badge }}"><span style="margin-left:auto;height:18px;padding:0 6px;display:inline-flex;align-items:center;background:var(--ui-info);color:var(--ui-bg);font:500 11px 'IBM Plex Sans',sans-serif">Update</span></sc-if><sc-if value="{{ n.chev }}"><span aria-hidden="true" style="margin-left:{{ n.chevML }};display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat"></span></sc-if></div><sc-if value="{{ n.sub }}"><div style="display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Media</span><sc-for list="{{ n.subs }}" as="x" hint-placeholder-count="2"><div data-act="nav-{{ x.t }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px 0 12px;background:{{ x.bg }};color:{{ x.fg }};font-weight:{{ x.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat"></span>{{ x.t }}</div></sc-for><sc-if value="{{ n.hasSites }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Publishing</span></sc-if><sc-for list="{{ n.subs2 }}" as="x2" hint-placeholder-count="1"><div data-act="nav-{{ x2.t }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px 0 12px;background:{{ x2.bg }};color:{{ x2.fg }};font-weight:{{ x2.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat"></span>{{ x2.t }}</div></sc-for></div></sc-if> <div data-act="nav-{{ n.key }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat"></span>{{ n.t }}<sc-if value="{{ n.badge }}"><span style="margin-left:auto;height:18px;padding:0 6px;display:inline-flex;align-items:center;background:var(--ui-info);color:var(--ui-bg);font:500 11px 'IBM Plex Sans',sans-serif">Update</span></sc-if><sc-if value="{{ n.chev }}"><span aria-hidden="true" style="margin-left:{{ n.chevML }};display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat"></span></sc-if></div><sc-if value="{{ n.sub }}"><div style="display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Media</span><sc-for list="{{ n.subs }}" as="x" hint-placeholder-count="2"><div data-act="nav-{{ x.t }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px 0 12px;background:{{ x.bg }};color:{{ x.fg }};font-weight:{{ x.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat"></span>{{ x.t }}</div></sc-for><sc-if value="{{ n.hasSites }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Publishing</span></sc-if><sc-for list="{{ n.subs2 }}" as="x2" hint-placeholder-count="1"><div data-act="nav-{{ x2.t }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px 0 12px;background:{{ x2.bg }};color:{{ x2.fg }};font-weight:{{ x2.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat"></span>{{ x2.t }}</div></sc-for><sc-if value="{{ n.hasAccess }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Access</span></sc-if><sc-for list="{{ n.subs3 }}" as="x3" hint-placeholder-count="1"><div data-act="nav-{{ x3.t }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px 0 12px;background:{{ x3.bg }};color:{{ x3.fg }};font-weight:{{ x3.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x3.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x3.ic }}.svg') center / contain no-repeat"></span>{{ x3.t }}</div></sc-for></div></sc-if>
</sc-for> </sc-for>
<sc-if value="{{ contentShow }}"><div style="display:flex;flex-direction:column;gap:2px;margin-top:8px"><span style="padding:6px 10px 2px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Content</span><sc-if value="{{ contentColl }}"><div style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat"></span><span style="flex:1">All content types</span><span style="font-size:12px;color:var(--ui-text-muted)">{{ contentCount }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat"></span></div></sc-if><sc-for list="{{ contentRows }}" as="c" hint-placeholder-count="3"><div data-act="nav-content-{{ c.key }}" title="{{ c.name }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:{{ c.bg }};color:{{ c.fg }};font-weight:{{ c.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat"></span><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ c.name }}</span></div></sc-for><sc-if value="{{ contentMore }}"><div style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat"></span><span>{{ contentMore }}</span></div></sc-if></div></sc-if> <sc-if value="{{ contentShow }}"><div style="display:flex;flex-direction:column;gap:2px;margin-top:8px"><span style="padding:6px 10px 2px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Content</span><sc-if value="{{ contentColl }}"><div style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat"></span><span style="flex:1">All content types</span><span style="font-size:12px;color:var(--ui-text-muted)">{{ contentCount }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat"></span></div></sc-if><sc-for list="{{ contentRows }}" as="c" hint-placeholder-count="3"><div data-act="nav-content-{{ c.key }}" title="{{ c.name }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:{{ c.bg }};color:{{ c.fg }};font-weight:{{ c.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat"></span><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ c.name }}</span></div></sc-for><sc-if value="{{ contentMore }}"><div style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat"></span><span>{{ contentMore }}</span></div></sc-if></div></sc-if>
<div style="flex:1"></div> <div style="flex:1"></div>
@@ -369,7 +369,7 @@
</sc-if> </sc-if>
{{ purgeDlg }} {{ purgeDlg }}
<sc-if value="{{ navDrawer }}"><div data-act="mnav-close" style="position:absolute;inset:0;z-index:60;display:flex;background:var(--rc-overlay)"><div role="navigation" aria-label="Main" style="width:300px;max-width:85%;height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column;padding:12px;gap:2px"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><span style="flex:1;font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">RenewCMS</span></div><sc-for list="{{ nav }}" as="n" hint-placeholder-count="3"><div data-act="nav-{{ n.key }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat"></span><span style="flex:1">{{ n.t }}</span><sc-if value="{{ n.chev }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat"></span></sc-if></div><sc-if value="{{ n.sub }}"><div style="display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Media</span><sc-for list="{{ n.subs }}" as="x" hint-placeholder-count="2"><div data-act="nav-{{ x.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x.bg }};color:{{ x.fg }};font-weight:{{ x.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat"></span>{{ x.t }}</div></sc-for><sc-if value="{{ n.hasSites }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Publishing</span></sc-if><sc-for list="{{ n.subs2 }}" as="x2" hint-placeholder-count="1"><div data-act="nav-{{ x2.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x2.bg }};color:{{ x2.fg }};font-weight:{{ x2.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat"></span>{{ x2.t }}</div></sc-for></div></sc-if></sc-for><sc-if value="{{ contentShow }}"><div style="display:flex;flex-direction:column;gap:2px;margin-top:8px"><span style="padding:6px 10px 2px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Content</span><sc-if value="{{ contentColl }}"><div style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat"></span><span style="flex:1">All content types</span><span style="font-size:12px;color:var(--ui-text-muted)">{{ contentCount }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat"></span></div></sc-if><sc-for list="{{ contentRows }}" as="c" hint-placeholder-count="3"><div data-act="nav-content-{{ c.key }}" title="{{ c.name }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;background:{{ c.bg }};color:{{ c.fg }};font-weight:{{ c.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkiLine truncated <sc-if value="{{ navDrawer }}"><div data-act="mnav-close" style="position:absolute;inset:0;z-index:60;display:flex;background:var(--rc-overlay)"><div role="navigation" aria-label="Main" style="width:300px;max-width:85%;height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column;padding:12px;gap:2px"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><span style="flex:1;font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">RenewCMS</span></div><sc-for list="{{ nav }}" as="n" hint-placeholder-count="3"><div data-act="nav-{{ n.key }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat"></span><span style="flex:1">{{ n.t }}</span><sc-if value="{{ n.chev }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat"></span></sc-if></div><sc-if value="{{ n.sub }}"><div style="display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Media</span><sc-for list="{{ n.subs }}" as="x" hint-placeholder-count="2"><div data-act="nav-{{ x.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x.bg }};color:{{ x.fg }};font-weight:{{ x.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat"></span>{{ x.t }}</div></sc-for><sc-if value="{{ n.hasSites }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Publishing</span></sc-if><sc-for list="{{ n.subs2 }}" as="x2" hint-placeholder-count="1"><div data-act="nav-{{ x2.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x2.bg }};color:{{ x2.fg }};font-weight:{{ x2.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat"></span>{{ x2.t }}</div></sc-for><sc-if value="{{ n.hasAccess }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Access</span></sc-if><sc-for list="{{ n.subs3 }}" as="x3" hint-placeholder-count="1"><div data-act="nav-{{ x3.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x3.bg }};color:{{ x3.fg }};font-weight:{{ x3.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x3.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x3.ic }}.svg') center / contain no-repeat"></span>{{ x3.t }}</div></sc-for></div></sc-if></sc-for><sc-if value="{{ contentShow }}"><div style="display:flex;flex-direction:column;gap:2px;margin-top:8px"><span style="padding:6px 10px 2px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Content</span><sc-if value="{{ contentColl }}"><div style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat"></span><span style="flex:1">All content types</span><span stLine truncated
<sc-if value="{{ lk.sheet }}"><div style="position:absolute;inset:0;z-index:55;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="dialog" aria-label="Link" style="width:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column"><div style="display:flex;justify-content:center;padding-top:8px"><span style="display:block;width:36px;height:4px;background:var(--ui-border-accented)"></span></div><div style="display:flex;flex-direction:column;gap:2px;padding:12px 16px 12px;border-bottom:1px solid var(--ui-border)"><span style="font-weight:600;font-size:16px;line-height:24px;color:var(--ui-text-highlighted)">timber windows</span><span style="font:400 12px/18px 'IBM Plex Mono',monospace;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">/articles/winter-checklist</span></div><div style="display:flex;flex-direction:column;padding:8px 0 16px"><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit link</span><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat"></span>Open link</span><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat"></span>Remove link</span></div></div></div></sc-if> <sc-if value="{{ lk.sheet }}"><div style="position:absolute;inset:0;z-index:55;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="dialog" aria-label="Link" style="width:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column"><div style="display:flex;justify-content:center;padding-top:8px"><span style="display:block;width:36px;height:4px;background:var(--ui-border-accented)"></span></div><div style="display:flex;flex-direction:column;gap:2px;padding:12px 16px 12px;border-bottom:1px solid var(--ui-border)"><span style="font-weight:600;font-size:16px;line-height:24px;color:var(--ui-text-highlighted)">timber windows</span><span style="font:400 12px/18px 'IBM Plex Mono',monospace;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">/articles/winter-checklist</span></div><div style="display:flex;flex-direction:column;padding:8px 0 16px"><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit link</span><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat"></span>Open link</span><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat"></span>Remove link</span></div></div></div></sc-if>
<sc-if value="{{ edSheet }}"><div style="position:absolute;inset:0;z-index:55;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="dialog" aria-modal="true" aria-label="Details" style="width:100%;height:92%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column"><div style="display:flex;justify-content:center;padding-top:8px;flex-shrink:0"><span style="display:block;width:36px;height:4px;background:var(--ui-border-accented)"></span></div><div style="padding:8px 16px 0 16px;display:flex;align-items:center;gap:12px;flex-shrink:0"><span style="flex:1;font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Details</span><span data-act="modal-close" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></div><div style="flex:1;min-height:0;overflow:hidden;padding:12px 16px 16px;display:flex;flex-direction:column;gap:16px"><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="meta-toggle" role="button" tabindex="0" aria-expanded="{{ metaOpen }}" aria-controls="meta-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ metaRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Site and authors</span></div><sc-if value="{{ metaOpen }}"><div id="meta-body" style="display:flex;flex-direction:column;gap:16px">{{ edMeta }}</div></sc-if><sc-if value="{{ metaClosed }}"><span style="padding-left:22px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ metaSummary }}</span></sc-if></div><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="seo-toggle" role="button" tabindex="0" aria-expanded="{{ seoOpen }}" aria-controls="seo-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ seoRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Search and sharing</span></div><sc-if value="{{ seoOpen }}"><div id="seo-body" style="display:flex;flex-direction:column;gap:12px">{{ edSeo }}</div></sc-if><sc-if value="{{ seoClosed }}"><span style="padding-left:22px;color:{{ seoFg }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ seoSummary }}</span></sc-if></div><div style="display:grid;grid-template-columns:auto 1fr;gap:8px 12px"><span style="color:var(--ui-text-muted)">Created</span><span>12 Mar 2026, 09:14</span><span style="color:var(--ui-text-muted)">Updated</span><span>Today, 11:02</span><span style="color:var(--ui-text-muted)">ID</span><span style="font-family:'IBM Plex Mono',monospace">12</span></div><div style="display:flex;flex-direction:column;gap:6px"><span style="color:var(--ui-text-muted)">Public API URL</span><span style="padding:6px 8px;background:var(--ui-bg-elevated);font:400 12px 'IBM Plex Mono',monospace">/v1/article/12</span></div></div></div></div></sc-if> <sc-if value="{{ edSheet }}"><div style="position:absolute;inset:0;z-index:55;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="dialog" aria-modal="true" aria-label="Details" style="width:100%;height:92%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column"><div style="display:flex;justify-content:center;padding-top:8px;flex-shrink:0"><span style="display:block;width:36px;height:4px;background:var(--ui-border-accented)"></span></div><div style="padding:8px 16px 0 16px;display:flex;align-items:center;gap:12px;flex-shrink:0"><span style="flex:1;font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Details</span><span data-act="modal-close" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></div><div style="flex:1;min-height:0;overflow:hidden;padding:12px 16px 16px;display:flex;flex-direction:column;gap:16px"><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="meta-toggle" role="button" tabindex="0" aria-expanded="{{ metaOpen }}" aria-controls="meta-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ metaRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Site and authors</span></div><sc-if value="{{ metaOpen }}"><div id="meta-body" style="display:flex;flex-direction:column;gap:16px">{{ edMeta }}</div></sc-if><sc-if value="{{ metaClosed }}"><span style="padding-left:22px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ metaSummary }}</span></sc-if></div><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="seo-toggle" role="button" tabindex="0" aria-expanded="{{ seoOpen }}" aria-controls="seo-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ seoRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Search and sharing</span></div><sc-if value="{{ seoOpen }}"><div id="seo-body" style="display:flex;flex-direction:column;gap:12px">{{ edSeo }}</div></sc-if><sc-if value="{{ seoClosed }}"><span style="padding-left:22px;color:{{ seoFg }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ seoSummary }}</span></sc-if></div><div style="display:grid;grid-template-columns:auto 1fr;gap:8px 12px"><span style="color:var(--ui-text-muted)">Created</span><span>12 Mar 2026, 09:14</span><span style="color:var(--ui-text-muted)">Updated</span><span>Today, 11:02</span><span style="color:var(--ui-text-muted)">ID</span><span style="font-family:'IBM Plex Mono',monospace">12</span></div><div style="display:flex;flex-direction:column;gap:6px"><span style="color:var(--ui-text-muted)">Public API URL</span><span style="padding:6px 8px;background:var(--ui-bg-elevated);font:400 12px 'IBM Plex Mono',monospace">/v1/article/12</span></div></div></div></div></sc-if>
<sc-if value="{{ integSearch }}"><dc-import name="IntegrationSearch" s="{{ s.search }}" mobile="{{ mob }}" hint-size="100%,100%"></dc-import></sc-if> <sc-if value="{{ integSearch }}"><dc-import name="IntegrationSearch" s="{{ s.search }}" mobile="{{ mob }}" hint-size="100%,100%"></dc-import></sc-if>
@@ -391,7 +391,7 @@ class Component extends DCLogic {
const k = s.kind || 'home' const k = s.kind || 'home'
const navItems = s.role === 'user' ? [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['authors', 'feather', 'Authors'], ['activity', 'history', 'Activity']] : [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['authors', 'feather', 'Authors'], ['users', 'users', 'Users'], ['schema', 'shapes', 'Schema'], ['activity', 'history', 'Activity'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']] const navItems = s.role === 'user' ? [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['authors', 'feather', 'Authors'], ['activity', 'history', 'Activity']] : [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['authors', 'feather', 'Authors'], ['users', 'users', 'Users'], ['schema', 'shapes', 'Schema'], ['activity', 'history', 'Activity'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']]
const cr0 = s.crumbs || [], setOpen = s.nav === 'settings' || !!s.sideOpen, parentOn = (key) => s.nav === key && (key !== 'settings' || cr0.length <= 1) const cr0 = s.crumbs || [], setOpen = s.nav === 'settings' || !!s.sideOpen, parentOn = (key) => s.nav === key && (key !== 'settings' || cr0.length <= 1)
const nav = navItems.map(([key, icon, t]) => { const on = parentOn(key), open = key === 'settings' && setOpen; return { key, icon, t, bg: on ? 'var(--rc-sand)' : 'transparent', badge: key === 'settings' && !!s.navBadge, fg: on ? 'var(--rc-on-sand)' : open ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on || open ? 500 : 400, chev: key === 'settings' ? (open ? 'chevron-down' : 'chevron-right') : '', chevML: key === 'settings' && s.navBadge ? '8px' : 'auto', sub: open, hasSites: !s.role || s.role === 'owner', subs2: (!s.role || s.role === 'owner') ? (() => { const o = cr0[cr0.length - 1] === 'Sites'; return [{ t: 'Sites', ic: 'globe', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 }] })() : [], subs: ['Variants', 'Cache'].map(x => { const o = cr0[cr0.length - 1] === x; return { t: x, ic: x === 'Variants' ? 'layers' : 'database', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 } }) } }) const nav = navItems.map(([key, icon, t]) => { const on = parentOn(key), open = key === 'settings' && setOpen; return { key, icon, t, bg: on ? 'var(--rc-sand)' : 'transparent', badge: key === 'settings' && !!s.navBadge, fg: on ? 'var(--rc-on-sand)' : open ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on || open ? 500 : 400, chev: key === 'settings' ? (open ? 'chevron-down' : 'chevron-right') : '', chevML: key === 'settings' && s.navBadge ? '8px' : 'auto', sub: open, hasSites: !s.role || s.role === 'owner', subs2: (!s.role || s.role === 'owner') ? (() => { const o = cr0[cr0.length - 1] === 'Sites'; return [{ t: 'Sites', ic: 'globe', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 }] })() : [], hasAccess: s.role !== 'user', subs3: s.role === 'user' ? [] : [(() => { const o = cr0[1] === 'Roles'; return { t: 'Roles', ic: 'shield', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 } })()], subs: ['Variants', 'Cache'].map(x => { const o = cr0[cr0.length - 1] === x; return { t: x, ic: x === 'Variants' ? 'layers' : 'database', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 } }) } })
const CT = s.content || [], ctOn = s.contentOn, ctMax = 5, ctVis = CT.slice(0, Math.max(ctMax, CT.findIndex(x => x.key === ctOn) + 1)), contentRows = (m ? (s.contentOpen ? ctVis : []) : ctVis).map(x => { const on = x.key === ctOn; return { key: x.key, name: x.name, icon: x.icon || 'file-text', bg: on ? 'var(--rc-sand)' : 'transparent', fg: on ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: on ? 500 : 400 } }) const CT = s.content || [], ctOn = s.contentOn, ctMax = 5, ctVis = CT.slice(0, Math.max(ctMax, CT.findIndex(x => x.key === ctOn) + 1)), contentRows = (m ? (s.contentOpen ? ctVis : []) : ctVis).map(x => { const on = x.key === ctOn; return { key: x.key, name: x.name, icon: x.icon || 'file-text', bg: on ? 'var(--rc-sand)' : 'transparent', fg: on ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: on ? 500 : 400 } })
const crumbs = (s.crumbs || []).map((t, i, a) => ({ t, sep: i > 0, fg: i === a.length - 1 ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', fw: i === a.length - 1 ? 500 : 400 })) const crumbs = (s.crumbs || []).map((t, i, a) => ({ t, sep: i > 0, fg: i === a.length - 1 ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', fw: i === a.length - 1 ? 500 : 400 }))
const L = s.list || {} const L = s.list || {}
@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<meta name="design_doc_mode" content="canvas">
<link rel="icon" href="brand/favicon.svg">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>body{margin:0;font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px}*{box-sizing:border-box}a{color:inherit;text-underline-offset:3px}a:hover{opacity:.8}code{font-family:'IBM Plex Mono',monospace;font-size:12px}</style>
</helmet>
<div ref="{{ rootRef }}" data-screen-label="Roles screens" style="background:var(--rc-canvas);color:var(--ui-text);padding:64px;display:flex;flex-direction:column;gap:72px;width:max-content;min-width:100%">
<div style="display:flex;align-items:flex-end;gap:32px;max-width:1400px">
<div style="flex:1;display:flex;flex-direction:column;gap:10px">
<h1 style="margin:0;font:600 36px/44px 'IBM Plex Sans',sans-serif;letter-spacing:-0.02em;color:var(--ui-text-highlighted)">Screens: roles and permissions</h1>
<p style="margin:0;max-width:900px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">Roles list, role editor with the content grid and plain-language preview, delete role, Users changes and ownership transfer, the role-aware shell, 403 copy, activity events, profile, API keys, and a six-step flow for creating a role. Every state at 1440 px (shown at 50%) and 390 px (shown at 75%); the switch shows dark. Specs in screens/roles.md. Same shell and tokens as <a href="RenewCMS Screens Back-office.dc.html">the back-office board</a>.</p>
</div>
<div role="radiogroup" aria-label="Theme" style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)">
<sc-for list="{{ modes }}" as="m" hint-placeholder-count="3">
<button role="radio" aria-checked="{{ m.on }}" onClick="{{ m.pick }}" style="all:unset;cursor:pointer;height:36px;padding:0 14px;display:flex;align-items:center;font-weight:500;font-size:14px;background:{{ m.bg }};color:{{ m.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">{{ m.label }}</button>
</sc-for>
</div>
</div>
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="9">
<section data-screen-label="{{ g.label }}" style="display:flex;flex-direction:column;gap:28px">
<div style="display:flex;flex-direction:column;gap:6px;max-width:1100px">
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2>
<div style="display:flex;gap:16px;flex-wrap:wrap;font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)"><span>{{ g.route }}</span><span style="color:var(--ui-text-muted)">{{ g.api }}</span><span style="color:var(--ui-text-muted)">Auth: {{ g.auth }}</span></div>
<p style="margin:4px 0 0;font-size:15px;line-height:24px;color:var(--ui-text-toned);text-wrap:pretty">{{ g.note }}</p>
</div>
<div style="display:flex;flex-wrap:wrap;gap:48px 40px;align-items:flex-start">
<sc-for list="{{ g.states }}" as="st" hint-placeholder-count="4">
<div style="display:flex;flex-direction:column;gap:12px;width:1037px">
<div style="display:flex;flex-direction:column;gap:2px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}<span style="margin-left:10px;padding:1px 6px;border:{{ st.chipBd }};color:var(--ui-text-toned);font:500 11px/16px 'IBM Plex Mono',monospace;vertical-align:middle">{{ st.scopeLabel }}</span></span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span></div>
<div style="display:flex;gap:24px;align-items:flex-start">
<div style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1440px;height:900px;zoom:0.5"><sc-if value="{{ st.isSchema }}"><dc-import name="RolesFrame" s="{{ st.s }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if></div></div>
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><sc-if value="{{ st.isSchema }}"><dc-import name="RolesFrame" s="{{ st.s }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if></div></div>
</div>
<p style="margin:0;font-size:12px;line-height:18px;color:var(--ui-text-muted);text-wrap:pretty"><b style="color:var(--ui-text-toned)">Copy:</b> {{ st.copy }}</p>
</div>
</sc-for>
</div>
</section>
</sc-for>
<sc-for list="{{ tables }}" as="t" hint-placeholder-count="3">
<section style="display:flex;flex-direction:column;gap:10px;max-width:1240px">
<h2 style="margin:0;font:600 22px/30px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ t.title }}</h2>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
<div style="display:grid;grid-template-columns:{{ t.tpl }};gap:16px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><sc-for list="{{ t.cols }}" as="c" hint-placeholder-count="3"><span>{{ c }}</span></sc-for></div>
<sc-for list="{{ t.rows }}" as="r" hint-placeholder-count="4"><div style="display:grid;grid-template-columns:{{ t.tpl }};gap:16px;padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><sc-for list="{{ r.cells }}" as="c" hint-placeholder-count="3"><span style="color:var(--ui-text-highlighted);text-wrap:pretty;overflow-wrap:anywhere">{{ c }}</span></sc-for></div></sc-for>
</div>
</section>
</sc-for>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;default&quot;:&quot;light&quot;,&quot;options&quot;:[&quot;system&quot;,&quot;light&quot;,&quot;dark&quot;],&quot;tsType&quot;:&quot;string&quot;}}">
class Component extends DCLogic {
state = { mode: this.props.theme ?? 'light', spec: { groups: [], tables: [] } }
rootRef = React.createRef()
light = { '--ui-bg': '#ffffff', '--ui-bg-muted': '#fafafa', '--ui-bg-elevated': '#f3f2f0', '--ui-bg-accented': '#e6e4e0', '--ui-bg-inverted': '#111111', '--ui-text-dimmed': '#999999', '--ui-text-muted': '#666666', '--ui-text-toned': '#444444', '--ui-text': '#222222', '--ui-text-highlighted': '#111111', '--ui-text-inverted': '#ffffff', '--ui-border': '#e6e4e0', '--ui-border-muted': '#f3f2f0', '--ui-border-accented': '#8a8783', '--ui-primary': '#111111', '--ui-secondary': '#635643', '--ui-success': '#315b33', '--ui-info': '#34536d', '--ui-warning': '#6c4700', '--ui-error': '#882c21', '--rc-sand': '#e8dfd1', '--rc-sel': '#e8dfd1', '--rc-on-sel': '#111111', '--rc-on-sand': '#111111', '--rc-success-soft': '#eaefea', '--rc-info-soft': '#ebeef0', '--rc-warning-soft': '#f0ede6', '--rc-error-soft': '#f3eae9', '--rc-overlay': 'rgb(10 10 10 / 0.4)', '--rc-shadow-pop': '0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06)', '--rc-shadow-modal': '0 16px 48px rgb(17 17 17 / 0.18)', '--rc-canvas': '#ecebe8' }
dark = { '--ui-bg': '#111111', '--ui-bg-muted': '#0a0a0a', '--ui-bg-elevated': '#1a1a1a', '--ui-bg-accented': '#222222', '--ui-bg-inverted': '#fafafa', '--ui-text-dimmed': '#666666', '--ui-text-muted': '#999999', '--ui-text-toned': '#cfccc7', '--ui-text': '#e6e4e0', '--ui-text-highlighted': '#fafafa', '--ui-text-inverted': '#111111', '--ui-border': '#333333', '--ui-border-muted': '#222222', '--ui-border-accented': '#666666', '--ui-primary': '#fafafa', '--ui-secondary': '#d4c5b0', '--ui-success': '#a3caa3', '--ui-info': '#a5c2db', '--ui-warning': '#dfb67d', '--ui-error': '#fba394', '--rc-sand': '#d4c5b0', '--rc-sel': '#222222', '--rc-on-sel': '#fafafa', '--rc-on-sand': '#111111', '--rc-success-soft': '#1f241f', '--rc-info-soft': '#202428', '--rc-warning-soft': '#2a251e', '--rc-error-soft': '#2c201f', '--rc-overlay': 'rgb(0 0 0 / 0.6)', '--rc-shadow-pop': '0 4px 16px rgb(0 0 0 / 0.5)', '--rc-shadow-modal': '0 16px 48px rgb(0 0 0 / 0.6)', '--rc-canvas': '#1c1c1c' }
resolved() { if (this.state.mode === 'system') return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; return this.state.mode }
apply() { const el = this.rootRef.current; if (!el) return; const vars = this.resolved() === 'dark' ? this.dark : this.light; for (const k in vars) el.style.setProperty(k, vars[k]); document.body.style.background = vars['--rc-canvas'] }
componentDidMount() { this.apply(); import('./roles-spec.js').then(x => this.setState({ spec: { groups: x.groups, tables: x.tables } })).catch(() => {}) }
componentDidUpdate(p) { if (p.theme !== this.props.theme && this.props.theme) this.setState({ mode: this.props.theme }); this.apply() }
renderVals() {
const modes = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([k, label]) => { const on = this.state.mode === k; return { label, on, bg: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-bg)' : 'var(--ui-text-toned)', pick: () => this.setState({ mode: k }) } })
const groups = this.state.spec.groups.map(g => ({ ...g, states: g.states.map(s => ({ ...s, isMedia: !!(s.s && s.s.frame === 'media'), isSchema: !(s.s && s.s.frame === 'media'), scopeLabel: s.scope === 'later' ? 'Later' : 'Available now', chipBd: s.scope === 'later' ? '1px dashed var(--ui-border-accented)' : '1px solid var(--ui-border-accented)' })) }))
const tables = this.state.spec.tables.map(t => ({ ...t, rows: t.rows.map(r => ({ cells: r })) }))
return { rootRef: this.rootRef, modes, groups, tables }
}
}
</script>
</body>
</html>
+316
View File
@@ -0,0 +1,316 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<sc-if value="{{ framed }}" hint-placeholder-val="{{ true }}"><dc-import name="AppFrame" s="{{ shell }}" mobile="{{ mob }}" slot-body="{{ main }}" slot-overlays="{{ ov }}" hint-size="100%,100%"></dc-import></sc-if>
<sc-if value="{{ bare }}"><div style="width:{{ bw }};height:{{ bh }};overflow:hidden;position:relative;background:var(--ui-bg-muted);color:var(--ui-text);font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px">{{ main }}</div></sc-if>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:1440,&quot;height&quot;:900},&quot;s&quot;:{&quot;editor&quot;:null,&quot;tsType&quot;:&quot;object&quot;},&quot;mobile&quot;:{&quot;editor&quot;:null,&quot;tsType&quot;:&quot;boolean&quot;}}">
class Component extends DCLogic {
renderVals() {
const s = this.props.s || {}, m = !!this.props.mobile
const P = t => Object.fromEntries(t.split(';').filter(x => x.trim()).map(x => { const i = x.indexOf(':'); const k = x.slice(0, i).trim(); return [k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase()), x.slice(i + 1).trim()] }))
const h = React.createElement
const D = (t, ...c) => h('div', { style: P(t) }, ...c)
const S = (t, ...c) => h('span', { style: P(t) }, ...c)
const I = (n, z = 16, t = '') => h('span', { 'aria-hidden': 'true', style: { display: 'inline-block', flexShrink: 0, width: z, height: z, backgroundColor: 'currentColor', WebkitMask: 'url("https://api.iconify.design/lucide/' + n + '.svg") center / contain no-repeat', mask: 'url("https://api.iconify.design/lucide/' + n + '.svg") center / contain no-repeat', ...(t ? P(t) : {}) } })
const mono = 'font-family:\'IBM Plex Mono\',monospace;'
const bh = m ? 44 : 32, ih = m ? 44 : 32, fs = m ? 16 : 13
const B = (label, o = {}) => {
const k = o.k || 'outline'
const bg = k === 'primary' ? 'var(--ui-primary)' : k === 'danger' ? 'var(--ui-error)' : k === 'ghost' ? 'transparent' : 'var(--ui-bg)'
const fg = k === 'primary' || k === 'danger' ? 'var(--ui-bg)' : k === 'del' ? 'var(--ui-error)' : 'var(--ui-text-highlighted)'
const bd = k === 'primary' || k === 'danger' ? bg : k === 'ghost' ? 'transparent' : 'var(--ui-border-accented)'
return S('height:' + (o.h || bh) + 'px;' + (o.w ? 'width:' + o.w + 'px;' : '') + 'padding:0 ' + (o.px ?? 12) + 'px;display:flex;align-items:center;justify-content:center;gap:6px;background:' + bg + ';color:' + fg + ';border:1px solid ' + bd + ';font-weight:500;font-size:' + (o.fs || 13) + 'px;white-space:nowrap;flex-shrink:0;opacity:' + (o.dis ? 0.4 : 1) + ';' + (o.full ? 'flex:1;' : '') + (o.ring ? 'outline:2px solid var(--ui-primary);outline-offset:2px;' : ''), o.icon && I(o.icon, o.z || 16), label)
}
const IB = (ic, o = {}) => B(null, { k: 'ghost', icon: ic, w: o.w || (m ? 44 : 28), h: o.h || (m ? 44 : 28), px: 0, dis: o.dis, ring: o.ring, z: o.z || 16 })
const Alert = (tone, icon, title, text, ...actions) => D('display:flex;gap:10px;padding:12px 14px;background:var(--rc-' + tone + '-soft);color:var(--ui-' + tone + ')', S('display:flex;padding-top:2px', I(icon)), D('flex:1;display:flex;flex-direction:column;gap:8px', D('display:flex;flex-direction:column;gap:2px', S('font-weight:600', title), text && S('color:var(--ui-text-toned);text-wrap:pretty', text)), actions.length > 0 && D('display:flex;gap:8px;flex-wrap:wrap', ...actions.map((a, i) => B(a, { icon: i === 0 && /again|Retry|Reload/.test(a) ? 'rotate-cw' : null, z: 14, h: m ? 44 : 28, fs: 12, px: 10, k: 'outline' })))))
const Later = (t = 'Later') => S('align-self:flex-start;height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned);white-space:nowrap;flex-shrink:0;font:500 11px/16px \'IBM Plex Mono\',monospace', t)
const BDG = { req: 'border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', uniq: 'border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', neu: 'background:var(--rc-sand);color:var(--rc-on-sand)', chg: 'background:var(--rc-info-soft);color:var(--ui-info)', err: 'background:var(--rc-error-soft);color:var(--ui-error)', warn: 'background:var(--rc-warning-soft);color:var(--ui-warning)', built: 'border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', ok: 'background:var(--rc-success-soft);color:var(--ui-success)' }
const Bd = (t, k, ic) => S('height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:500;white-space:nowrap;flex-shrink:0;' + BDG[k], ic && I(ic, 12), t)
const Seg = (opts, on, w) => D('display:flex;border:1px solid var(--ui-border-accented);width:' + (w || '100%'), ...opts.map(([k, t]) => S('flex:1;height:' + bh + 'px;padding:0 10px;display:flex;align-items:center;justify-content:center;font-weight:' + (on === k ? 500 : 400) + ';background:' + (on === k ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on === k ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)') + ';font-size:' + fs + 'px', t)))
const SwR = (on, lab, help, dis) => D('display:flex;gap:16px;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid var(--ui-border);opacity:' + (dis ? 0.6 : 1), D('display:flex;flex-direction:column;gap:2px;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted)', lab), help && S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', help)), S('width:32px;height:18px;padding:2px;display:flex;flex-shrink:0;justify-content:' + (on ? 'flex-end' : 'flex-start') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg-accented)') + ';' + (on ? '' : 'outline:1px solid var(--ui-border-accented);outline-offset:-1px'), S('width:14px;height:14px;background:var(--ui-bg)')))
const Sw = (on, lab, help, dis) => D('display:flex;gap:12px;align-items:flex-start;opacity:' + (dis ? 0.6 : 1), S('width:32px;height:18px;margin-top:2px;padding:2px;display:flex;flex-shrink:0;justify-content:' + (on ? 'flex-end' : 'flex-start') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg-accented)') + ';' + (on ? '' : 'outline:1px solid var(--ui-border-accented);outline-offset:-1px'), S('width:14px;height:14px;background:var(--ui-bg)')), D('display:flex;flex-direction:column;gap:2px', lab && S('font-weight:500;color:var(--ui-text-highlighted)', lab), help && S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', help)))
const Chk = on => S('width:16px;height:16px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg)') + ';color:var(--ui-bg)', on && I('check', 12))
const Skel = (w, hh = 14) => S('display:block;height:' + hh + 'px;width:' + w + ';background:var(--ui-bg-accented)')
const F = (lab, val, o = {}) => D('display:flex;flex-direction:column;gap:6px;min-width:0', (lab || o.count) && D('display:flex;justify-content:space-between;gap:8px', S('font-weight:500;color:var(--ui-text-highlighted)', lab, o.req && ' *', o.opt && S('font-weight:400;color:var(--ui-text-muted)', ' (optional)')), o.count && S(mono + 'font-size:12px;color:' + (o.countErr ? 'var(--ui-error)' : 'var(--ui-text-muted)'), o.count)),
D('display:flex;align-items:' + (o.area ? 'flex-start' : 'center') + ';min-height:' + (o.area ? (m ? 120 : 88) : ih) + 'px;border:1px solid ' + (o.err ? 'var(--ui-error)' : o.focus ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';' + (o.err ? 'box-shadow:inset 0 0 0 1px var(--ui-error);' : o.focus ? 'box-shadow:inset 0 0 0 1px var(--ui-primary);' : '') + 'background:' + (o.dis || o.lock ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)') + ';font-size:' + fs + 'px;opacity:' + (o.dis ? 0.6 : 1), o.pre, S('flex:1;min-width:0;padding:' + (o.area ? '8px 10px' : '0 10px') + ';color:' + (o.ph || val === '' ? 'var(--ui-text-muted)' : 'var(--ui-text-highlighted)') + ';' + (o.area ? '' : 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;') + (o.mono ? mono + 'font-size:' + (m ? 14 : 12) + 'px;' : ''), val === '' && o.phText ? o.phText : val), o.auto && Bd('Auto', 'neu'), o.auto && I('pencil', 14, 'margin:0 10px 0 8px;color:var(--ui-text-muted)'), o.lock && I('lock', 14, 'margin-right:10px;color:var(--ui-text-muted)'), o.sel && I('chevron-down', 14, 'margin-right:10px;color:var(--ui-text-muted)'), o.suffix && S('padding-right:10px;color:var(--ui-text-muted);font-size:12px', o.suffix), o.ico && I(o.ico, 14, 'margin-right:10px;color:var(--ui-text-muted)')),
o.err && S('display:flex;gap:6px;align-items:flex-start;color:var(--ui-error);font-size:12px;line-height:18px', I('circle-alert', 14, 'margin-top:2px'), o.err), o.help && S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', o.help))
const Shell = (o, body) => D('position:absolute;inset:0;z-index:' + (o.z || 50) + ';background:var(--rc-overlay);display:flex;align-items:' + (m ? 'flex-end' : 'flex-start') + ';justify-content:center;padding:' + (m ? '0' : (o.top || 140) + 'px 16px 16px'),
D('width:' + (m ? '100%' : (o.w || 460) + 'px') + ';max-height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column', m && D('display:flex;justify-content:center;padding-top:8px;flex-shrink:0', S('display:block;width:36px;height:4px;background:var(--ui-border-accented)')),
D('padding:20px 24px 0;display:flex;align-items:flex-start;gap:12px', D('flex:1;display:flex;flex-direction:column;gap:4px', S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', o.title), o.desc && S('color:var(--ui-text-muted);text-wrap:pretty', o.desc)), o.later && Later(), o.close && S('width:' + (m ? 44 : 28) + 'px;height:' + (m ? 44 : 28) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))),
D('padding:20px 24px 0;display:flex;flex-direction:column;gap:16px;min-height:0;overflow:hidden', body),
D('padding:20px 24px;display:flex;flex-direction:' + (m ? 'column-reverse' : 'row') + ';justify-content:flex-end;gap:8px;flex-shrink:0', o.cancel && B(o.cancel, { dis: o.loading }), o.confirm && S('height:' + bh + 'px;padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:' + (o.tone === 'danger' ? 'var(--ui-error)' : 'var(--ui-primary)') + ';color:var(--ui-bg);font-weight:500;white-space:nowrap;opacity:' + (o.cdis ? 0.4 : 1), o.loading && I('loader-circle'), o.confirm))))
const Sheet = (o, body, foot) => D('position:absolute;inset:0;z-index:' + (o.z || 40) + ';display:flex;align-items:flex-end;background:var(--rc-overlay)',
D('width:100%;height:92%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column', D('display:flex;justify-content:center;padding-top:8px;flex-shrink:0', S('display:block;width:36px;height:4px;background:var(--ui-border-accented)')),
D('flex-shrink:0;display:flex;align-items:center;gap:12px;padding:4px 6px 4px 16px;height:56px;border-bottom:1px solid var(--ui-border)', S('flex:1;min-width:0;font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', o.title), o.later && Later(), S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))),
D('flex:1;min-height:0;overflow:hidden;padding:16px;display:flex;flex-direction:column;gap:16px', body), foot && D('flex-shrink:0;padding:12px 16px;border-top:1px solid var(--ui-border);display:flex;gap:8px', foot)))
const Toast = (tone, icon, title, text, action) => D('position:absolute;z-index:60;display:flex;gap:10px;padding:12px 14px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);' + (m ? 'left:16px;right:16px;bottom:' + (s.toastB || 16) + 'px' : 'right:24px;bottom:24px;width:356px'), S('margin-top:2px;display:flex;color:var(--ui-' + tone + ')', I(icon)), D('flex:1;display:flex;flex-direction:column;gap:6px', D('display:flex;flex-direction:column;gap:2px', S('font-weight:600;color:var(--ui-text-highlighted)', title), S('color:var(--ui-text-muted)', text)), action && B(action, { icon: 'rotate-cw', z: 14, h: m ? 44 : 28, fs: 12, px: 10 })), I('x'))
const Offline = () => D('flex-shrink:0;display:flex;align-items:center;gap:10px;padding:10px 16px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted)', I('wifi-off'), S('flex:1', 'You\'re offline. Changes will be saved when you reconnect.'))
const Tip = t => D('position:absolute;z-index:30;width:260px;padding:8px 10px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', t)
const Page = (pad, ...c) => D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;position:relative;overflow:hidden;padding:' + pad + ';gap:16px', ...c)
const Title = (t, sub, ...acts) => D('display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap;flex-shrink:0', D('flex:1;min-width:200px;display:flex;flex-direction:column;gap:2px', !m && S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', t), sub && S('color:var(--ui-text-muted)', sub)), ...acts)
const pad = m ? '16px' : '24px'
const Fab = l => m && D('position:absolute;left:16px;right:16px;bottom:16px;height:48px;z-index:5;display:flex;align-items:center;justify-content:center;gap:8px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;font-size:15px;box-shadow:var(--rc-shadow-pop)', I('plus', 18), l)
const Center = (icon, title, text, ...acts) => D('flex:1;display:flex;align-items:center;justify-content:center;padding:24px', D('max-width:420px;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center', S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:var(--ui-bg-elevated);color:var(--ui-text-toned)', I(icon, 20)), S('font:600 24px/32px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', title), S('color:var(--ui-text-muted);text-wrap:pretty', text), D('display:flex;gap:8px', ...acts)))
const Empty = (icon, title, text, action) => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:40px 24px;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center', S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:var(--ui-bg-elevated);color:var(--ui-text-toned)', I(icon, 20)), S('font-weight:600;font-size:15px;color:var(--ui-text-highlighted)', title), S('color:var(--ui-text-muted);max-width:360px;text-wrap:pretty', text), action && B(action, { k: 'primary', icon: 'plus', px: 16 }))
// ---------- roles data
BDG.lock = 'background:var(--ui-bg-elevated);color:var(--ui-text-toned)'; BDG.owner = 'background:var(--rc-sand);color:var(--rc-on-sand)'; BDG.admin = 'background:var(--rc-info-soft);color:var(--ui-info)'; BDG.own = 'border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned)'
const TY = ['Articles', 'Events', 'Case studies'], AL = ['read', 'create', 'update', 'delete', 'publish'], ALL = { read: 'Read', create: 'Create', update: 'Update', delete: 'Delete', publish: 'Publish' }, SC = { n: 'None', o: 'Own', a: 'All' }
const GEN = [
{ id: 'schema', name: 'Schema', items: [['View content types', 'See content types and their fields.', 'see content types and fields', 'b'], ['Create and edit content types', 'Add types and change their fields and rules.', 'create and edit content types', 'b'], ['Delete content types', 'Remove a type. Its entries go with it.', 'delete content types', 'b']] },
{ id: 'media', name: 'Media', items: [['View the media library', 'Browse images and files.', 'browse the media library', 'b'], ['Upload files', 'Add images and files to the library.', 'upload files', 'b'], ['Delete files', 'Remove files from the library.', 'delete files', 's']] },
{ id: 'authors', name: 'Authors', items: [['View authors', 'See author profiles.', 'see authors', 'b'], ['Create authors', 'Add an author profile.', 'create authors', 'b'], ['Edit authors', 'Change an author profile.', 'edit authors', 's'], ['Delete authors', 'Remove an author profile.', 'delete authors', 'b']] },
{ id: 'activity', name: 'Activity', items: [['See activity', 'Read the log of who did what.', 'read the activity log', 's'], ['Export activity', 'Download the log as a CSV file.', 'export the activity log', 'b']] },
{ id: 'users', name: 'Users and invites', items: [['View users', 'See members and pending invites.', 'see users and invites', 'b'], ['Invite users', 'Send an invitation with a role.', 'invite users', 'b'], ['Change a user\'s role', 'Move a user to another role.', 'change a user\'s role', 'b'], ['Remove users', 'Take a user off the site.', 'remove users', 'b']] },
{ id: 'settings', name: 'Settings', items: [['View settings', 'Open Settings pages, read-only.', 'read settings', 'b'], ['Edit general settings', 'Site name, language and defaults.', 'edit general settings', 'b'], ['Manage sites', 'Create, edit and set the default site.', 'manage sites', 'b'], ['See API keys', 'List the keys on this site.', 'see API keys', 's'], ['Create API keys', 'A key follows its creator\'s role.', 'create API keys', 'b'], ['Revoke API keys', 'Stop a key from working.', 'revoke API keys', 'b'], ['Delete a site', 'Remove a site and its content.', 'delete a site', 'o']] },
{ id: 'roles', name: 'Roles', items: [['View roles', 'See roles and what they allow.', 'see roles', 'b'], ['Manage roles', 'Create, edit, duplicate and delete roles.', 'manage roles', 'b'], ['Edit roles with owner-only power', 'Change a role that carries owner-only abilities.', 'edit roles with owner-only power', 'o']] },
{ id: 'system', name: 'System', items: [['Apply software updates', 'Install a new version of RenewCMS.', 'apply software updates', 'o'], ['Edit activity retention', 'How long the activity log is kept.', 'edit activity retention', 'o'], ['Transfer ownership', 'Make another member the owner.', 'transfer ownership', 'o']] }
]
const fullG = own => Object.fromEntries(GEN.map(g => [g.id, g.items.map(it => it[3] === 's' ? 'a' : it[3] === 'o' ? (own ? '1' : '0') : '1').join('')]))
const mkT = (a, ov) => Object.fromEntries(TY.map(t => [t, (ov && ov[t]) || a]))
const ROLES = {
owner: { name: 'Owner', desc: 'Everything on the site. One per site.', built: 1, lock: 1, members: 1, g: fullG(true), all: 'a1aaa', t: mkT('a1aaa') },
admin: { name: 'Admin', desc: 'Everything except owner-only settings.', built: 1, members: 2, g: fullG(false), all: 'a1aaa', t: mkT('a1aaa') },
writer: { name: 'Writer', desc: 'Reads everything and writes content. Can\'t delete or publish.', built: 1, members: 5, g: { schema: '100', media: '11n', authors: '10o0', activity: 'o0', users: '0000', settings: '000n000', roles: '000', system: '000' }, all: 'a1ann', t: mkT('a1ann') },
contrib: { name: 'Contributor', desc: 'Creates content and edits only their own.', members: 4, g: { schema: '100', media: '11n', authors: '10o0', activity: 'n0', users: '0000', settings: '000n000', roles: '000', system: '000' }, all: 'a1onn', t: mkT('a1onn', { 'Case studies': 'a0nnn' }) },
lead: { name: 'Content lead', desc: 'Edits and publishes all content.', members: 1, g: { schema: '100', media: '11a', authors: '10a0', activity: 'a1', users: '1000', settings: '100n000', roles: '100', system: '000' }, all: 'a1aaa', t: mkT('a1aaa') },
blank: { name: '', desc: '', members: 0, g: Object.fromEntries(GEN.map(g => [g.id, g.items.map(it => it[3] === 's' ? 'n' : '0').join('')])), all: 'n0nnn', t: mkT('n0nnn') }
}
const ON = c => c !== '0' && c !== 'n'
const cnt = (R, g) => R.g[g.id].split('').filter(ON).length
const j = a => a.length < 2 ? a.join('') : a.slice(0, -1).join(', ') + ' and ' + a[a.length - 1]
const phr = c => { const f = (ch, vb) => ch === 'a' ? vb + ' any entry' : ch === 'o' ? vb + ' only their own' : null; const can = [f(c[0], 'read'), c[1] === '1' ? 'create entries' : null, f(c[2], 'edit'), f(c[3], 'delete'), f(c[4], 'publish')]; const cl = can.filter(Boolean), no = ['read', 'create', 'edit', 'delete', 'publish'].filter((x, i) => !can[i]); return cl.length ? 'Can ' + j(cl) + '.' + (no.length ? ' Can\'t ' + j(no) + '.' : '') : 'No access to entries.' }
const pv = R => {
const groups = {}; TY.forEach(t => { const p = phr(R.t[t]); (groups[p] = groups[p] || []).push(t) })
const content = Object.entries(groups).map(([p, ts]) => ({ pre: ts.length === TY.length ? 'All content types' : j(ts), txt: p }))
const other = [], none = []
GEN.forEach(g => { const say = []; g.items.forEach((it, i) => { const ch = R.g[g.id][i]; if (it[3] === 'o' || !ON(ch)) return; say.push(it[3] === 's' ? (ch === 'o' ? it[2] + ', only their own' : it[2]) : it[2]) }); if (say.length) other.push({ pre: g.name, txt: 'Can ' + j(say) + '.' }); else if (g.id !== 'system') none.push(g.name) })
return { content, other, none }
}
const RB = n => n === 'Owner' ? Bd('Owner', 'owner', 'crown') : n === 'Admin' ? Bd('Admin', 'admin', 'shield') : n === 'Writer' ? Bd('Writer', 'req', 'pencil') : Bd(n, 'own', 'user-round')
const IC = (ic, t) => S('display:inline-flex;margin-top:2px;color:' + (t || 'var(--ui-text-toned)'), I(ic, 16))
// ---------- shared pieces
const Tri = (v, o = {}) => D('display:flex;border:1px solid var(--ui-border-accented);flex-shrink:0;opacity:' + (o.dis ? 0.55 : 1) + ';' + (o.fill ? 'flex:1;' : '') + (o.ring ? 'outline:2px solid var(--ui-primary);outline-offset:2px;' : ''),
...['n', 'o', 'a'].map(c => { const on = v === c, bl = (o.block || '').includes(c); return S('flex:1;min-width:' + (m ? 0 : 34) + 'px;height:' + (m ? 44 : 26) + 'px;padding:0 6px;display:flex;align-items:center;justify-content:center;gap:3px;font-size:' + (m ? 14 : 12) + 'px;font-weight:' + (on ? 600 : 400) + ';background:' + (on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)') + ';box-shadow:' + (on ? 'inset 0 -2px 0 var(--ui-primary)' : 'none') + ';opacity:' + (bl ? 0.45 : 1), bl && I('lock', 10), SC[c]) }))
const OnOff = (on, o = {}) => D('display:flex;align-items:center;gap:8px;opacity:' + (o.dis ? 0.55 : 1), S('width:32px;height:18px;padding:2px;display:flex;flex-shrink:0;justify-content:' + (on ? 'flex-end' : 'flex-start') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg-accented)') + ';' + (on ? '' : 'outline:1px solid var(--ui-border-accented);outline-offset:-1px'), S('width:14px;height:14px;background:var(--ui-bg)')), S('font-size:12px;min-width:20px;color:var(--ui-text-toned)', on ? 'On' : 'Off'))
const Card = (title, desc, right, ...c) => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:12px;flex-shrink:0;min-width:0', D('display:flex;gap:12px;align-items:flex-start', D('flex:1;display:flex;flex-direction:column;gap:2px', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', title), desc && S('color:var(--ui-text-muted);text-wrap:pretty', desc)), right), ...c)
const Menu = (right, top, items) => D('position:absolute;z-index:30;right:' + right + 'px;top:' + top + 'px;width:224px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px', ...items.map(([ic, t, dis, hot, danger]) => D('min-height:' + (m ? 44 : 32) + 'px;padding:6px 8px;display:flex;align-items:center;gap:8px;opacity:' + (dis ? 0.5 : 1) + ';background:' + (hot ? 'var(--ui-bg-elevated)' : 'transparent') + ';color:' + (danger ? 'var(--ui-error)' : 'var(--ui-text-highlighted)'), I(ic, 16), S('flex:1', t))))
const Banner = (tone, icon, title, text, ...acts) => Alert(tone, icon, title, text, ...acts)
const Link = t => S('text-decoration:underline;text-underline-offset:3px;font-weight:500;color:var(--ui-text-highlighted)', t)
const Pill = (t, on) => S('height:' + (m ? 44 : 28) + 'px;padding:0 10px;display:inline-flex;align-items:center;gap:6px;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';font-weight:' + (on ? 600 : 500) + ';font-size:' + (m ? 14 : 12) + 'px;white-space:nowrap', on && I('check', 12), t)
const crumbs = (...c) => { shell.crumbs = c; shell.mTitle = c[c.length - 1] }
// ---------- preview panel
const PvBody = (R, o = {}) => { const p = pv(R); const L = (ic, pre, txt, chg) => D('display:flex;gap:10px', IC(ic), D('flex:1;display:flex;flex-direction:column;gap:1px;min-width:0', S('font-size:12px;line-height:18px;font-weight:500;color:var(--ui-text-muted)', pre, chg && ' '), S('color:var(--ui-text-highlighted);text-wrap:pretty', txt)), chg && Bd('Changed', 'chg')); return D('display:flex;flex-direction:column;gap:12px',
D('display:flex;flex-direction:column;gap:10px', S('font-weight:600;color:var(--ui-text-highlighted)', 'Content'), ...p.content.map((x, i) => L(R.all === 'n0nnn' && !R.t.Articles.includes('a') && x.txt.startsWith('No') ? 'ban' : 'file-text', x.pre, x.txt, o.chg && i === 0))),
p.other.length > 0 && D('display:flex;flex-direction:column;gap:10px;padding-top:12px;border-top:1px solid var(--ui-border)', S('font-weight:600;color:var(--ui-text-highlighted)', 'Everything else'), ...p.other.map(x => L('check', x.pre, x.txt))),
p.none.length > 0 && D('display:flex;gap:10px;padding-top:12px;border-top:1px solid var(--ui-border)', IC('ban'), D('display:flex;flex-direction:column;gap:1px', S('font-size:12px;line-height:18px;font-weight:500;color:var(--ui-text-muted)', 'No access to'), S('color:var(--ui-text-highlighted)', j(p.none) + '.'))),
R.members > 0 && S('padding-top:12px;border-top:1px solid var(--ui-border);color:var(--ui-text-muted);font-size:12px;line-height:18px', 'Applies to ' + R.members + (R.members === 1 ? ' member' : ' members') + ', and to the API keys they created.')) }
const PvPanel = (R, o = {}) => D('width:320px;flex-shrink:0;align-self:flex-start;max-height:100%;overflow:hidden;background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:12px;' + (o.ro ? 'opacity:1;' : ''), D('display:flex;flex-direction:column;gap:2px', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'What this role can do'), S('color:var(--ui-text-muted)', o.ro ? 'A summary of what this role allows.' : 'Updates as you edit.')), PvBody(R, o))
const PvSheet = (R, o = {}) => Sheet({ title: o.title || 'What this role can do', z: 45 }, [S('color:var(--ui-text-muted)', o.sub || 'Updates as you edit.'), PvBody(R, o)], o.foot)
// ---------- content grid
const CG = (R, o = {}) => {
const chg = o.chg || [], esc = o.esc, base = R.all
const uni = i => TY.every(t => R.t[t][i] === R.t[TY[0]][i]) ? R.t[TY[0]][i] : null
const cell = (t, i, v) => { const a = AL[i], key = t + '.' + a, bl = esc && a === 'publish' ? 'a' : ''; const ctl = i === 1 ? OnOff(v === '1', { dis: o.ro }) : Tri(v, { dis: o.ro, block: bl, ring: o.ring === key }); return D('position:relative;display:flex;align-items:center', chg.includes(key) && S('position:absolute;left:-8px;top:50%;margin-top:-3px;width:6px;height:6px;background:var(--ui-info)'), ctl) }
const tcol = '118px 84px 118px 118px 118px'
const nameCell = (t, custom) => D('display:flex;flex-direction:column;gap:2px;min-width:0', D('display:flex;align-items:center;gap:6px;flex-wrap:wrap', S('font-weight:500;color:var(--ui-text-highlighted)', t), t === 'Articles' && Bd('Built-in', 'built', 'lock'), custom && Bd('Custom', 'warn'), custom && Link('Reset')), t === 'Articles' && S('font-size:12px;line-height:18px;color:var(--ui-text-muted)', 'Schema locked'))
const presets = D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', S('color:var(--ui-text-muted);margin-right:4px', 'Quick presets'), Pill('Read only', base === 'a0nnn'), Pill('Write own', base === 'a1onn'), Pill('Full content', base === 'a1aaa'))
if (m) {
const card = (t, v, custom, all) => D('border:1px solid var(--ui-border);padding:12px;display:flex;flex-direction:column;gap:10px;background:' + (all ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)'), all ? S('font-weight:600;color:var(--ui-text-highlighted)', 'All content types') : nameCell(t, custom), ...AL.map((a, i) => D('display:flex;align-items:center;gap:10px', S('width:64px;color:var(--ui-text-toned);flex-shrink:0', ALL[a]), i === 1 ? OnOff(v[i] === '1', { dis: o.ro }) : Tri(v[i], { fill: true, dis: o.ro, block: esc && a === 'publish' ? 'a' : '' }))))
return D('display:flex;flex-direction:column;gap:10px', presets, card('', base, false, true), ...TY.map(t => card(t, R.t[t], R.t[t] !== base)))
}
const hd = D('display:grid;grid-template-columns:minmax(120px,1fr) ' + tcol + ';gap:8px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500;align-items:center', S('', 'Content type'), ...AL.map(a => S('display:flex;align-items:center;gap:4px', ALL[a], !o.ro && I('chevron-down', 12))))
const row = (t, v, custom, all) => D('display:grid;grid-template-columns:minmax(120px,1fr) ' + tcol + ';gap:8px;padding:10px 12px;border-top:1px solid var(--ui-border);align-items:center;background:' + (all ? 'var(--ui-bg-muted)' : 'var(--ui-bg)'), all ? D('display:flex;flex-direction:column', S('font-weight:600;color:var(--ui-text-highlighted)', 'All content types'), S('font-size:12px;line-height:18px;color:var(--ui-text-muted)', 'Sets every row')) : nameCell(t, custom), ...AL.map((a, i) => v[i] === null ? D('display:flex;align-items:center;gap:6px', Tri(null, { dis: o.ro }), Bd('Mixed', 'warn')) : cell(t, i, v[i])))
const allv = AL.map((a, i) => uni(i))
return D('display:flex;flex-direction:column;gap:12px', !o.ro && presets, D('border:1px solid var(--ui-border);position:relative', hd, row('', allv, false, true), ...TY.map(t => row(t, R.t[t], R.t[t] !== base, false)), o.tip && Tip2(o.tip)))
}
const Tip2 = t => D('position:absolute;z-index:30;right:8px;top:78px;width:272px;padding:10px 12px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', t)
// ---------- general permissions
const GP = (R, o = {}) => { const open = o.open || ['schema']; return D('display:flex;flex-direction:column', ...GEN.map(g => { const isO = open.includes(g.id), c = cnt(R, g), tot = g.items.length - g.items.filter(it => it[3] === 'o').length || g.items.length; return D('border-top:1px solid var(--ui-border)', D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 48 : 40) + 'px;padding:4px 0', I(isO ? 'chevron-down' : 'chevron-right', 16, 'color:var(--ui-text-muted)'), S('font-weight:600;color:var(--ui-text-highlighted);flex:1', g.name), g.id === 'system' && Bd('Owner only', 'lock', 'lock'), S('font-size:12px;color:var(--ui-text-muted)', g.id === 'system' ? '0 of 3 enabled' : c + ' of ' + tot + ' enabled')), isO && D('display:flex;flex-direction:column;padding:0 0 4px 24px', ...g.items.map((it, i) => { const ch = R.g[g.id][i], lockd = it[3] === 'o', ctl = lockd ? D('display:flex;align-items:center;gap:8px', Bd('Owner only', 'lock', 'lock'), OnOff(ch === '1', { dis: true })) : it[3] === 's' ? Tri(ch, { dis: o.ro, fill: m }) : OnOff(ON(ch), { dis: o.ro }); return D('display:flex;flex-direction:' + (m ? 'column' : 'row') + ';gap:' + (m ? 8 : 16) + 'px;align-items:' + (m ? 'stretch' : 'center') + ';justify-content:space-between;padding:10px 0;border-top:1px solid var(--ui-border-muted)', D('display:flex;flex-direction:column;gap:2px;min-width:0;opacity:' + (lockd ? 0.7 : 1), S('font-weight:500;color:var(--ui-text-highlighted)', it[0]), S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', it[1])), ctl) }))) })) }
// ---------- editor
const Editor = (R, o = {}) => {
const ro = !!o.ro, isNew = !!o.isNew
const head = D('display:flex;flex-direction:column;gap:12px;flex-shrink:0',
D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', D('display:flex;align-items:center;gap:6px;color:var(--ui-text-toned);font-weight:500', I('arrow-left', 16), 'Roles'), D('flex:1'), !m && Title0(R, o)),
m && Title0(R, o),
o.alerts && D('display:flex;flex-direction:column;gap:8px', ...o.alerts),
D('display:grid;grid-template-columns:' + (m ? '1fr' : 'minmax(0,280px) minmax(0,1fr)') + ';gap:12px', F('Name', R.name, { req: !ro, lock: ro, err: o.nameErr, focus: o.focus === 'name', phText: 'For example, Contributor', help: o.nameHelp }), F('Description', R.desc, { lock: ro, opt: !ro, phText: 'What this role is for', focus: o.focus === 'desc' })))
const sections = D('display:flex;flex-direction:column;gap:16px;opacity:' + (o.saving ? 0.6 : 1), o.showHead !== false && head,
!o.onlyGeneral && Card('Content permissions', 'What this role can do with entries of each content type. Own means entries the person created.', null, CG(R, o)),
Card('General permissions', 'Everything outside entries.', !m && !ro ? S('color:var(--ui-text-toned);font-weight:500', 'Expand all') : null, GP(R, { ro, open: o.open })))
const scroll = o.scroll ? D('margin-top:-' + o.scroll + 'px;display:flex;flex-direction:column;gap:16px', sections) : sections
const col = D('flex:1;min-width:0;min-height:0;overflow:hidden;display:flex;flex-direction:column;padding-bottom:' + (o.bar ? 64 : 0) + 'px', scroll)
const bar = o.bar && D('position:absolute;left:0;right:0;bottom:0;z-index:20;display:flex;align-items:center;gap:' + (m ? 8 : 12) + 'px;padding:' + (m ? '8px 16px' : '10px 24px') + ';background:var(--ui-bg);border-top:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop)', ...o.bar)
const body = m ? col : D('flex:1;min-height:0;display:flex;gap:16px;align-items:flex-start', col, o.noPv ? null : PvPanel(R, { chg: o.pvChg, ro }))
return [Page(m ? '16px' : '24px', body, bar), o.over]
}
const Title0 = (R, o) => D('display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', o.isNew ? 'New role' : R.name), !o.isNew && (R.built ? Bd('Built-in', 'built') : Bd('Custom', 'own')), R.lock && Bd('Locked', 'lock', 'lock'), !o.isNew && S('color:var(--ui-text-muted)', R.members + (R.members === 1 ? ' member' : ' members')))
const SaveBar = (n, o = {}) => [m ? B('What this role can do', { icon: 'list-checks', full: true, h: 44 }) : D('display:flex;align-items:center;gap:8px;flex:1', S('width:6px;height:6px;background:var(--ui-info)'), S('font-weight:500;color:var(--ui-text-highlighted)', o.msg || (n + (n === 1 ? ' unsaved change' : ' unsaved changes')))), !m && S('flex:0'), B('Discard', { dis: o.saving }), S('height:' + bh + 'px;padding:0 14px;display:flex;align-items:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;opacity:' + (o.saving || o.sdis ? 0.4 : 1), o.saving && I('loader-circle'), o.saving ? 'Saving…' : 'Save changes')]
// ---------- tables used by several screens
const Th = (cols, ...c) => D('display:grid;grid-template-columns:' + cols + ';gap:16px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', ...c.map(x => S('', x)))
const Tr = (cols, o, ...c) => D('display:grid;grid-template-columns:' + cols + ';gap:16px;padding:0 12px;min-height:48px;align-items:center;border-top:1px solid var(--ui-border);position:relative;background:' + (o.sel ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (o.sel ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';opacity:' + (o.dim ? 0.55 : 1), ...c)
const Dots = ring => IB('ellipsis', { ring })
const Cd = (o, ...c) => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:12px;display:flex;flex-direction:column;gap:6px;opacity:' + (o.dim ? 0.55 : 1), ...c)
const Mu = t => S('color:var(--ui-text-muted)', t)
const Hi = t => S('font-weight:500;color:var(--ui-text-highlighted)', t)
const Add = (label, o = {}) => B(label, { k: 'primary', icon: 'plus', px: 14, dis: o.dis })
// ---------- data for the screens
const shell = { kind: 'slot', display: 'Marta Alves', role: s.role || 'owner', content: [{ key: 'events', name: 'Events', icon: 'files' }, { key: 'case-studies', name: 'Case studies', icon: 'files' }], contentOn: null, navBadge: false, nav: 'settings', sideOpen: true }
let main = null; const ov = []; let bare = false
const RL = ['owner', 'admin', 'writer', 'contrib', 'lead']
const k = s.k, v = s.v
if (v === 'list') {
crumbs('Settings', 'Roles'); const cols = '200px minmax(0,1fr) 90px 100px 40px', noEdit = k === 'noedit', rows = k === 'empty' ? RL.slice(0, 3) : RL
const sk = (w) => Skel(w, 14)
const tbl = D('background:var(--ui-bg);border:1px solid var(--ui-border);position:relative', Th(cols, 'Role', 'Description', 'Members', 'Type', ''),
...(k === 'loading' ? [1, 2, 3, 4, 5].map(() => Tr(cols, {}, sk('120px'), sk('70%'), sk('24px'), sk('64px'), sk('16px'))) : rows.map((r, i) => { const R = ROLES[r]; return Tr(cols, { sel: k === 'menu' && i === 3 }, D('display:flex;align-items:center;gap:8px', Hi(R.name), R.lock && Bd('Locked', 'lock', 'lock')), Mu(R.desc), S('', R.members), R.built ? Bd('Built-in', 'built') : Bd('Custom', 'own'), Dots(k === 'menu' && i === 3 || k === 'menub' && i === 2)) })),
k === 'menu' && Menu(24, 200, [['pencil', 'Edit', false, true], ['copy', 'Duplicate'], ['trash-2', 'Delete', false, false, true]]),
k === 'menub' && Menu(24, 152, [['pencil', 'Edit', false, true], ['copy', 'Duplicate'], ['trash-2', 'Delete', true]]), k === 'menub' && D('position:absolute;z-index:31;right:260px;top:196px;width:240px;padding:8px 10px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', 'Built-in roles can\'t be deleted. Duplicate it to make a custom role.'),
noEdit && k === 'noedit' && D('position:absolute;z-index:31;right:24px;top:-40px;width:260px;padding:8px 10px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', 'You can see roles, but only people who manage roles can create or change them.'))
const cards = D('display:flex;flex-direction:column;gap:8px', ...(k === 'loading' ? [1, 2, 3].map(() => Cd({}, sk('120px'), sk('90%'), sk('60px'))) : rows.map((r, i) => { const R = ROLES[r]; return Cd({}, D('display:flex;align-items:center;gap:8px', Hi(R.name), R.lock && Bd('Locked', 'lock', 'lock'), D('flex:1'), R.built ? Bd('Built-in', 'built') : Bd('Custom', 'own'), IB('ellipsis')), Mu(R.desc), S('font-size:12px;color:var(--ui-text-muted)', R.members + (R.members === 1 ? ' member' : ' members'))) })))
const list = k === 'error' ? Alert('error', 'circle-x', 'Couldn\'t load roles', 'GET /v1/role failed. Check your connection and try again.', 'Try again') : m ? cards : tbl
main = Page(m ? '16px 16px 88px' : '24px', Title('Roles', 'Who can do what on this site. Each user has one role.', B('New role', { k: 'primary', icon: 'plus', px: 14, dis: k === 'loading' || noEdit })),
noEdit && Banner('info', 'lock', 'You can see roles but not change them', 'Ask an owner or admin if a role needs to change.'),
k === 'empty' && Banner('info', 'info', 'No custom roles yet', 'The three built-in roles cover most sites. Create a role when someone needs something in between.', 'New role'),
list, m && Fab('New role'))
} else if (v === 'editor') {
const flow = s.flow
let R = ROLES.contrib, o = {}
crumbs('Settings', 'Roles', k === 'new' ? 'New role' : R.name)
if (k === 'new') { R = ROLES.blank; o = { isNew: true, focus: 'name', bar: SaveBar(0, { sdis: true, msg: 'Name your role to save it' }) }; shell.mTitle = 'New role' }
else if (k === 'custom') { o = { open: ['schema', 'media'] } }
else if (k === 'owner-edit') { R = ROLES.writer; o = { open: [], alerts: [Banner('warning', 'triangle-alert', '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.')], bar: SaveBar(2) }; shell.mTitle = 'Writer'; R = { ...R, t: { ...R.t, Events: 'a1aan' } } }
else if (k === 'admin-ro') { R = ROLES.writer; shell.role = 'admin'; shell.display = 'Jonas Weber'; o = { ro: true, open: [], alerts: [Banner('info', 'lock', 'Only the owner can edit built-in roles', 'You can read what Writer allows. To change it, duplicate it into a custom role.', 'Duplicate role')] } }
else if (k === 'locked') { R = ROLES.owner; o = { ro: true, open: [], alerts: [Banner('info', 'lock', 'The Owner role is locked', 'It always has every permission. To hand it to someone else, transfer ownership from Users.', 'Go to Users')] } }
else if (k === 'dirty') { R = { ...R, t: { ...R.t, Events: 'a1aan' }, g: { ...R.g, media: '11o' } }; o = { open: ['media'], chg: ['Events.update', 'Events.delete'], pvChg: true, bar: SaveBar(3) } }
else if (k === 'saving') { R = { ...R, t: { ...R.t, Events: 'a1aan' } }; o = { saving: true, open: [], bar: SaveBar(3, { saving: true }) } }
else if (k === 'saved') { o = { open: [], over: Toast('success', 'circle-check', 'Role saved', 'Contributor is updated for 4 members.') } }
else if (k === 'req') { R = { ...ROLES.blank, name: '' }; o = { isNew: true, nameErr: 'Give the role a name.', bar: SaveBar(0, { sdis: true, msg: '1 problem to fix' }) }; shell.mTitle = 'New role' }
else if (k === 'taken') { R = { ...ROLES.blank, name: 'Writer', desc: 'Reads and writes' }; o = { isNew: true, nameErr: 'A role named "Writer" already exists. Choose a different name.', bar: SaveBar(0, { sdis: true, msg: '1 problem to fix' }) }; shell.mTitle = 'New role' }
else if (k === 'esc') { shell.display = 'Elena Rossi'; shell.role = 'admin'; R = ROLES.contrib; R = { ...R, name: 'Contributor', t: { ...R.t, Articles: 'a1ono' } }; o = { open: [], esc: true, ring: 'Articles.publish', tip: 'You can only grant what your role has. Your role can publish only entries you created, so All isn\'t available.', bar: SaveBar(1) } }
else if (k === 'conflict') { o = { open: [], alerts: [Banner('warning', 'triangle-alert', '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.', 'Reload role', 'Review differences')], bar: SaveBar(2, { sdis: true, msg: '2 unsaved changes. Save is off until you reload' }) } }
else if (k === 'lowered') { shell.display = 'Elena Rossi'; shell.role = 'admin'; o = { ro: true, open: [], alerts: [Banner('error', 'circle-x', '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.', 'Reload page')] } }
else if (k === 'general') { o = { onlyGeneral: false, scroll: 360, open: ['activity', 'users', 'settings'], showHead: false } ; R = ROLES.lead }
else if (k === 'owneronly') { shell.display = 'Jonas Weber'; shell.role = 'admin'; R = ROLES.admin; o = { scroll: 300, open: ['roles', 'system'], showHead: false, ro: false, alerts: null } }
else if (k === 'custom-row') { R = ROLES.contrib; o = { open: [], chg: ['Case studies.read'], pvChg: false } }
else if (flow) {
const st = flow
if (st === 2) { R = { ...ROLES.blank, name: 'Contributor', desc: 'Writes posts and edits only their own' }; o = { isNew: true, focus: 'desc', open: [], bar: SaveBar(2) }; shell.mTitle = 'New role' }
if (st === 3) { R = { ...ROLES.blank, name: 'Contributor', desc: 'Writes posts and edits only their own', all: 'a1onn', t: mkT('a1onn') }; o = { isNew: true, open: [], bar: SaveBar(7), chg: ['Articles.read', 'Articles.create', 'Articles.update'], pvChg: true }; shell.mTitle = 'New role' }
if (st === 4) { R = { ...ROLES.contrib, g: { ...ROLES.contrib.g, schema: '100' } }; o = { isNew: true, open: ['schema', 'media'], scroll: 0, bar: SaveBar(9), noPv: false }; shell.mTitle = 'New role' }
if (st === 5) { R = ROLES.contrib; o = { isNew: true, open: [], bar: SaveBar(9), ring: null }; shell.mTitle = 'New role' }
if (st === 6) { o = { open: [], over: Toast('success', 'circle-check', 'Role created', 'Contributor is ready. Assign it from Users.') } }
}
const out = Editor(R, o)
main = out[0]; if (out[1]) ov.push(out[1])
if (s.sheet && m) ov.push(PvSheet(R, { chg: !!o.pvChg, foot: [B('Close', { full: true })] }))
} else if (v === 'pv') {
let R = ROLES.contrib; crumbs('Settings', 'Roles', 'Contributor')
if (k === 'empty') { R = { ...ROLES.blank, name: 'New role' } }
if (k === 'readonly') { R = ROLES.writer }
const o = { ro: k === 'readonly', noPv: false, open: [], pvChg: k === 'live', chg: k === 'live' ? ['Events.update'] : [] }
if (k === 'live') R = { ...R, t: { ...R.t, Events: 'a1aan' } }
if (k === 'empty') o.isNew = true
const out = Editor(R, o); main = out[0]
if (m) ov.push(PvSheet(R, { chg: k === 'live', ro: k === 'readonly', foot: [B('Close', { full: true })] }))
} else if (v === 'del') {
crumbs('Settings', 'Roles'); const cols = '200px minmax(0,1fr) 90px 100px 40px'
const rows = ['owner', 'admin', 'writer', 'contrib', 'lead']
main = Page(m ? '16px' : '24px', Title('Roles', 'Who can do what on this site. Each user has one role.', B('New role', { k: 'primary', icon: 'plus', px: 14 })), m ? D('display:flex;flex-direction:column;gap:8px', ...rows.slice(2).map(r => Cd({}, D('display:flex;gap:8px;align-items:center', Hi(ROLES[r].name), D('flex:1'), ROLES[r].built ? Bd('Built-in', 'built') : Bd('Custom', 'own')), Mu(ROLES[r].desc)))) : D('background:var(--ui-bg);border:1px solid var(--ui-border)', Th(cols, 'Role', 'Description', 'Members', 'Type', ''), ...rows.map(r => { const R = ROLES[r]; return Tr(cols, {}, Hi(R.name), Mu(R.desc), S('', R.members), R.built ? Bd('Built-in', 'built') : Bd('Custom', 'own'), Dots()) })))
const pick = (R, o = {}) => F('Move members to', o.val || '', { req: true, sel: true, phText: 'Choose a role', focus: o.open, help: 'They keep their API keys. Keys follow the new role.' })
const rp = (n, sum, dis, why) => D('padding:8px 10px;display:flex;flex-direction:column;gap:2px;opacity:' + (dis ? 0.5 : 1) + ';min-height:' + (m ? 52 : 44) + 'px;border-top:1px solid var(--ui-border-muted)', D('display:flex;gap:6px;align-items:center', Hi(n), dis && I('lock', 12)), S('font-size:12px;line-height:18px;color:var(--ui-text-muted)', dis ? why : sum))
if (k === 'blocked' || k === 'picker') ov.push(Shell({ title: 'Delete Contributor?', desc: '4 members have this role. Move them to another role first. Then Contributor can be deleted.', close: true, cancel: 'Cancel', confirm: 'Move 4 members and delete role', cdis: true, tone: 'danger', top: 140 }, [pick(null, { open: k === 'picker' }), k === 'picker' && D('border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);background:var(--ui-bg);margin-top:-8px', rp('Writer', 'Reads everything and writes content. Can\'t delete or publish.'), rp('Content lead', 'Edits and publishes all content.'), rp('Admin', 'Everything except owner-only settings.', true, 'Your role can\'t assign a role with more power than yours.'))]))
if (k === 'confirm') ov.push(Shell({ title: 'Delete Intern?', desc: 'No one has this role. This can\'t be undone.', close: true, cancel: 'Cancel', confirm: 'Delete role', tone: 'danger', top: 140 }, [Mu('Deleting a role doesn\'t affect content.')]))
if (k === 'builtin') ov.push(Shell({ title: 'Delete Writer?', desc: 'Built-in roles can\'t be deleted.', close: true, cancel: 'Close', confirm: 'Delete role', cdis: true, tone: 'danger', top: 140 }, [Banner('info', 'lock', 'Writer is built in', 'Duplicate it to make a custom role you can change or delete.', 'Duplicate role')]))
} else if (v === 'users') {
shell.nav = 'users'; shell.sideOpen = false; crumbs('Users'); const cols = 'minmax(0,1fr) minmax(0,1.2fr) 150px 110px 40px'
const U = [['Marta Alves', 'marta@renewbuild.example', 'Owner', 'Active'], ['Jonas Weber', 'jonas@renewbuild.example', 'Admin', 'Active'], ['Ines Duarte', 'ines@renewbuild.example', 'Writer', 'Active'], ['Pedro Lima', 'pedro@renewbuild.example', 'Contributor', 'Active'], ['sofia@renewbuild.example', 'Invited', 'Content lead', 'Pending']]
const rows = U.map((u, i) => i === 4 ? [u[0], '', u[2], 'Invite pending'] : u)
const tbl = D('background:var(--ui-bg);border:1px solid var(--ui-border);position:relative', Th(cols, 'Name', 'Email', 'Role', 'Status', ''), ...rows.map((u, i) => Tr(cols, { sel: k === 'menu' && i === 2 }, Hi(u[0]), Mu(u[1]), D('display:flex;align-items:center;gap:6px', RB(u[2]), i === 0 && I('lock', 14, 'color:var(--ui-text-muted)')), Mu(u[3]), Dots(k === 'menu' && i === 2 || k === 'ownermenu' && i === 0))),
k === 'menu' && Menu(24, 190, [['user-cog', 'Change role', false, true], ['mail', 'Send password reset'], ['user-x', 'Remove user', false, false, true]]),
k === 'ownermenu' && Menu(24, 96, [['user-cog', 'Change role', true], ['crown', 'Transfer ownership', false, true], ['user-x', 'Remove user', true]]), k === 'ownermenu' && D('position:absolute;z-index:31;right:260px;top:140px;width:250px;padding:8px 10px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', 'The owner can\'t be changed or removed. Transfer ownership first.'))
const cards = D('display:flex;flex-direction:column;gap:8px', ...rows.map(u => Cd({}, D('display:flex;gap:8px;align-items:center', Hi(u[0]), D('flex:1'), IB('ellipsis')), u[1] && Mu(u[1]), D('display:flex;gap:8px;align-items:center', RB(u[2]), Mu(u[3])))))
main = Page(m ? '16px 16px 88px' : '24px', Title('Users', '5 members and invites', B('Invite user', { k: 'primary', icon: 'user-plus', px: 14 })), m ? cards : tbl, m && Fab('Invite user'))
const radio = (n, sum, on, dis, why) => D('display:flex;gap:10px;padding:10px 12px;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border)') + ';background:' + (on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';opacity:' + (dis ? 0.5 : 1) + ';min-height:' + (m ? 56 : 52) + 'px', S('margin-top:2px;width:16px;height:16px;flex-shrink:0;border:1px solid var(--ui-border-accented);border-radius:50%;display:flex;align-items:center;justify-content:center;background:var(--ui-bg)', on && S('width:8px;height:8px;border-radius:50%;background:var(--ui-primary)')), D('flex:1;display:flex;flex-direction:column;gap:1px', D('display:flex;gap:6px;align-items:center', S('font-weight:600', n), dis && I('lock', 12), on && S('font-size:12px;font-weight:500', '· Current')), S('font-size:12px;line-height:18px;color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-muted)'), dis ? why : sum)))
const SUM = { Owner: ROLES.owner.desc, Admin: ROLES.admin.desc, Writer: ROLES.writer.desc, Contributor: ROLES.contrib.desc, 'Content lead': ROLES.lead.desc }
if (k === 'change') ov.push(Shell({ title: 'Change role for Ines Duarte', desc: 'Her API keys follow the new role.', close: true, cancel: 'Cancel', confirm: 'Change role', cdis: false, top: 100 }, D('display:flex;flex-direction:column;gap:8px', radio('Admin', SUM.Admin), radio('Writer', SUM.Writer, true), radio('Contributor', SUM.Contributor), radio('Content lead', SUM['Content lead']))))
if (k === 'changeblock') { shell.role = 'admin'; shell.display = 'Jonas Weber'; ov.push(Shell({ title: 'Change role for Ines Duarte', desc: 'Her API keys follow the new role.', close: true, cancel: 'Cancel', confirm: 'Change role', cdis: true, top: 100 }, D('display:flex;flex-direction:column;gap:8px', radio('Admin', SUM.Admin, false, false), radio('Owner', SUM.Owner, false, true, 'There is one owner. Transfer ownership to change who it is.'), radio('Writer', SUM.Writer, true), radio('Content lead', SUM['Content lead'], false, true, 'It can publish. Your role can\'t grant that.')))) }
if (k === 'invite') ov.push(Shell({ title: 'Invite user', desc: 'They get an email with a link to set a password.', close: true, cancel: 'Cancel', confirm: 'Send invite', top: 120 }, [F('Email', 'sofia@renewbuild.example', {}), F('Role', 'Writer', { sel: true, help: SUM.Writer }), Mu('Writer is the default. You can change a role later.')]))
if (k === 'pending') { shell.display = 'Marta Alves'; ov.push(D('display:contents')) }
if (k === 'tr1' || k === 'tr2' || k === 'tr3') {
const tit = 'Transfer ownership'
if (k === 'tr1') ov.push(Shell({ title: tit, desc: 'Choose who becomes the owner. Only members can be chosen.', close: true, cancel: 'Cancel', confirm: 'Continue', cdis: false, top: 100 }, D('display:flex;flex-direction:column;gap:8px', radio('Jonas Weber', 'jonas@renewbuild.example · Admin', true), radio('Ines Duarte', 'ines@renewbuild.example · Writer'), radio('Pedro Lima', 'pedro@renewbuild.example · Contributor'))))
if (k === 'tr2') ov.push(Shell({ title: tit + ' to Jonas Weber', desc: 'This takes effect straight away.', close: true, cancel: 'Back', confirm: 'Transfer ownership', cdis: true, tone: 'danger', top: 80 }, [D('display:flex;flex-direction:column;gap:6px', ...['Jonas Weber becomes the owner.', 'You become an Admin. You can\'t take ownership back unless he transfers it.', 'Only the owner can apply updates, delete a site and change activity retention.', 'Your API keys follow your new role.'].map(t => D('display:flex;gap:8px', IC('arrow-right'), S('text-wrap:pretty;color:var(--ui-text-highlighted)', t)))), F('Type jonas@renewbuild.example to confirm', 'jonas@renewbuild', { focus: true, help: 'The email must match exactly.' })]))
if (k === 'tr3') { main = Page(m ? '16px 16px 88px' : '24px', Title('Users', '5 members and invites', B('Invite user', { k: 'primary', icon: 'user-plus', px: 14, dis: false })), m ? cards : D('background:var(--ui-bg);border:1px solid var(--ui-border)', Th(cols, 'Name', 'Email', 'Role', 'Status', ''), ...[['Jonas Weber', 'jonas@renewbuild.example', 'Owner', 'Active'], ['Marta Alves', 'marta@renewbuild.example', 'Admin', 'Active'], ['Ines Duarte', 'ines@renewbuild.example', 'Writer', 'Active']].map(u => Tr(cols, {}, Hi(u[0]), Mu(u[1]), RB(u[2]), Mu(u[3]), Dots())))); shell.role = 'admin'; ov.push(Toast('success', 'circle-check', 'Ownership transferred', 'Jonas Weber is now the owner. You are an Admin.')) }
}
} else if (v === 'shell') {
shell.role = 'user'; shell.display = 'Pedro Lima'; shell.nav = k.startsWith('art') ? 'articles' : 'content'; shell.sideOpen = false
const cols = 'minmax(0,1fr) 140px 110px 120px 40px'
const lk = (t) => S('display:inline-flex;align-items:center;gap:4px;color:var(--ui-text-muted);font-size:12px', I('lock', 12), t)
const entry = (t, by, st, own, o2 = {}) => Tr(cols, { dim: false, sel: o2.sel }, D('display:flex;align-items:center;gap:8px;min-width:0', Hi(t), !own && lk('View only')), Mu(by), Bd(st, st === 'Published' ? 'ok' : 'req'), Mu('Oct 14'), Dots(o2.menu))
const cardE = (t, by, st, own) => Cd({}, D('display:flex;gap:8px;align-items:center', Hi(t), D('flex:1'), IB('ellipsis')), D('display:flex;gap:8px;align-items:center', Bd(st, st === 'Published' ? 'ok' : 'req'), Mu(by), !own && lk('View only')))
const A = [['How we price a bathroom renovation', 'Ines Duarte', 'Published', 1], ['Choosing lime plaster', 'Pedro Lima', 'Draft', 1], ['Retrofit grants in 2027', 'Jonas Weber', 'Published', 1], ['Site safety checklist', 'Pedro Lima', 'Draft', 1]]
const own = k === 'own' || k === 'ownmenu'
const E = [['Lime plaster workshop', 'Pedro Lima', 'Draft', 1], ['Open site day', 'Ines Duarte', 'Published', 0], ['Retrofit talk', 'Jonas Weber', 'Published', 0], ['Tool library evening', 'Pedro Lima', 'Draft', 1]]
const data = own ? E : A
const tbl = D('background:var(--ui-bg);border:1px solid var(--ui-border);position:relative', Th(cols, 'Title', 'Created by', 'Status', 'Updated', ''), ...data.map((r, i) => entry(r[0], r[1], r[2], r[3], { menu: (k === 'artmenu' && i === 1) || (k === 'ownmenu' && i === 1) })),
k === 'artmenu' && Menu(24, 140, [['pencil', 'Edit', false, true], ['copy', 'Duplicate'], ['trash-2', 'Delete', true, false, true]]),
k === 'artmenu' && D('position:absolute;z-index:31;right:260px;top:196px;width:230px;padding:8px 10px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', 'Your role can\'t delete articles.'),
k === 'ownmenu' && Menu(24, 140, [['pencil', 'Edit', true, true], ['copy', 'Duplicate'], ['trash-2', 'Delete', true, false, true]]),
k === 'ownmenu' && D('position:absolute;z-index:31;right:260px;top:150px;width:240px;padding:8px 10px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', 'You can only edit content you created.'))
if (k === 'art' || k === 'artmenu' || k === 'own' || k === 'ownmenu') {
const ttl = own ? 'Events' : 'Articles'; crumbs(own ? 'Content' : 'Articles', ...(own ? ['Events'] : [])); if (own) shell.contentOn = 'events'
main = Page(m ? '16px 16px 88px' : '24px', Title(ttl, own ? 'You can create entries and edit your own.' : 'You can write and edit all articles. Publishing is done by an editor.', B(own ? 'New entry' : 'New article', { k: 'primary', icon: 'plus', px: 14 })), own && Banner('info', 'info', 'Entries by others are view only', 'Your role lets you edit entries you created.'), m ? D('display:flex;flex-direction:column;gap:8px', ...data.map(r => cardE(r[0], r[1], r[2], r[3]))) : tbl, m && Fab(own ? 'New entry' : 'New article'))
}
if (k === 'entry') {
crumbs('Articles', 'Choosing lime plaster'); shell.mLead = 'arrow-left'
const side = D('width:' + (m ? 'auto' : '280px') + ';flex-shrink:0;display:flex;flex-direction:column;gap:12px;background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Status'), D('display:flex;align-items:center;gap:8px', Bd('Draft', 'req'), Mu('Saved at 11:02')), D('display:flex;gap:6px;color:var(--ui-text-muted);font-size:12px;line-height:18px', I('info', 14, 'margin-top:2px'), 'Ask an editor to publish.'))
const ed = D('flex:1;min-width:0;display:flex;flex-direction:column;gap:12px', F('Title', 'Choosing lime plaster', { req: true }), D('border:1px solid var(--ui-border-accented);background:var(--ui-bg);min-height:' + (m ? 220 : 380) + 'px;padding:12px;color:var(--ui-text-toned);text-wrap:pretty', 'Lime plaster lets a wall move moisture in and out. Before you pick one, check what the wall is made of.'))
main = Page(m ? '16px 16px 88px' : '24px', Title('Choosing lime plaster', null, B('Save draft', { k: 'primary', px: 14 })), D('display:flex;flex-direction:' + (m ? 'column' : 'row') + ';gap:16px;align-items:flex-start;flex:1;min-height:0', ed, side))
}
if (k === 'rule') {
shell.role = 'user'; crumbs('Home'); shell.nav = 'home'
const col = (t, sub, ...c) => D('flex:1;background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:10px;min-width:0', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', t), Mu(sub), ...c)
const r = (ic, t, ex) => D('display:flex;gap:8px;align-items:flex-start', IC(ic), D('display:flex;flex-direction:column;gap:2px', Hi(t), Mu(ex)))
main = Page(m ? '16px' : '24px', Title('Home', 'Pedro Lima · Contributor'), D('display:flex;flex-direction:' + (m ? 'column' : 'row') + ';gap:16px', col('Hidden', 'The person can never use it.', r('eye-off', 'Sections of the sidebar', 'Schema, Users, Integration and Settings are not listed.'), r('eye-off', 'Buttons for another area', 'No Publish button when the role can\'t publish.')), col('Disabled, with a reason', 'The person sees the thing but can\'t act on it.', r('ban', 'Delete on an entry they can see', 'Your role can\'t delete articles.'), r('ban', 'Edit on someone else\'s entry', 'You can only edit content you created.')), col('Read-only', 'The person may look but not change.', r('lock', 'Settings pages for an admin', 'A banner says who can edit.'))))
}
} else if (v === 'admin') {
shell.role = 'admin'; shell.display = 'Jonas Weber'; shell.nav = 'settings'; shell.sideOpen = true
if (k === 'ro') { crumbs('Settings', 'General'); main = Page(m ? '16px 16px 88px' : '24px', Title('General', 'Site name, language and defaults.'), Banner('info', 'lock', 'You can read these settings but not change them', 'Ask the owner to change them, or ask for a role that can edit settings.'), Card('Site', null, null, F('Site name', 'Renew Build', { lock: true }), F('Default language', 'English', { lock: true, sel: false })), D('display:flex;gap:8px;opacity:0.4', B('Save changes', { k: 'primary' }))) }
if (k === 'owneronly') { crumbs('Settings', 'Software'); main = Page(m ? '16px' : '24px', Title('Software', 'Version 2.8.0'), Card('Update available', 'Version 2.9.0 is ready to install.', Bd('Owner only', 'lock', 'lock'), D('display:flex;align-items:center;gap:12px;flex-wrap:wrap', B('Apply update', { k: 'primary', icon: 'download', dis: true }), S('color:var(--ui-text-muted)', 'Only the owner can apply updates.'))), Card('Activity retention', 'How long the activity log is kept.', Bd('Owner only', 'lock', 'lock'), F('Keep activity for', '12 months', { lock: true, help: 'Admins can read the log. Only the owner can change how long it is kept.' }))) }
} else if (v === 'forbid') {
shell.role = 'user'; shell.display = 'Pedro Lima'; shell.sideOpen = false; shell.nav = 'home'; crumbs('Roles')
if (k === 'page') main = Center('lock', 'You don\'t have access to this page', 'Your role, Contributor, doesn\'t include it. Ask an owner or admin if you need it.', B('Go to Articles', { k: 'primary', px: 16 }), B('See what I can do'))
if (k === 'missing') { crumbs('Articles', 'Choosing lime plaster'); shell.nav = 'articles'; main = Page(m ? '16px' : '24px', Title('Choosing lime plaster'), F('Title', 'Choosing lime plaster', {}), D('background:var(--ui-bg);border:1px solid var(--ui-border);min-height:180px;padding:12px;color:var(--ui-text-toned)', 'Lime plaster lets a wall move moisture in and out.')); ov.push(Toast('error', 'circle-x', 'You don\'t have permission to do that', 'Your role doesn\'t include Delete entries. Ask an owner or admin.')) }
if (k === 'locked') { shell.role = 'admin'; shell.display = 'Jonas Weber'; crumbs('Settings', 'Roles', 'Owner'); main = Page(m ? '16px' : '24px', Title('Owner'), F('Name', 'Owner', { lock: true })); ov.push(Toast('error', 'lock', 'This role is locked', 'The Owner role can\'t be changed or deleted. Transfer ownership from Users instead.')) }
} else if (v === 'act') {
shell.nav = 'activity'; shell.sideOpen = false; crumbs('Activity'); const cols = '28px minmax(0,1fr) 140px 120px 40px'
const E = [['shield', 'Role Contributor created', 'Marta Alves', 'Oct 14, 10:41'], ['pencil', 'Role Contributor edited', 'Marta Alves', 'Oct 14, 10:52'], ['user-cog', 'Ines Duarte\'s role changed from Writer to Content lead', 'Marta Alves', 'Oct 14, 11:02'], ['crown', 'Ownership transferred from Marta Alves to Jonas Weber', 'Marta Alves', 'Oct 15, 09:10'], ['trash-2', 'Role Intern deleted', 'Marta Alves', 'Oct 15, 09:30']]
const tbl = D('background:var(--ui-bg);border:1px solid var(--ui-border)', Th(cols, '', 'Event', 'By', 'When', ''), ...E.map((e, i) => Tr(cols, { sel: k === 'detail' && i === 1 }, I(e[0], 16, 'color:var(--ui-text-toned)'), Hi(e[1]), Mu(e[2]), Mu(e[3]), I('chevron-right', 16, 'color:var(--ui-text-muted)'))))
const cards = D('display:flex;flex-direction:column;gap:8px', ...E.map(e => Cd({}, D('display:flex;gap:8px', IC(e[0]), Hi(e[1])), Mu(e[2] + ' · ' + e[3]))))
main = Page(m ? '16px' : '24px', Title('Activity', 'Everything that happened on this site.'), m ? cards : tbl)
if (k === 'detail') { const body = D('display:flex;flex-direction:column;gap:12px', D('display:flex;flex-direction:column;gap:2px', Hi('Role Contributor edited'), Mu('Marta Alves · Oct 14, 10:52')), D('display:flex;flex-direction:column;gap:6px;padding:12px;background:var(--ui-bg-elevated)', S('font-weight:600;color:var(--ui-text-highlighted)', 'What changed'), D('display:flex;gap:8px', Bd('Added', 'ok', 'plus'), S('color:var(--ui-text-highlighted)', 'Delete entries (Own)')), D('display:flex;gap:8px', Bd('Removed', 'err', 'minus'), S('color:var(--ui-text-highlighted)', 'Publish'))), Mu('4 members have this role.')); ov.push(m ? Sheet({ title: 'Activity details' }, body, [B('Close', { full: true })]) : D('position:absolute;inset:0;z-index:40;background:var(--rc-overlay);display:flex;justify-content:flex-end', D('width:440px;height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column', D('padding:20px 24px 0;display:flex;align-items:center', S('flex:1;font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Activity details'), IB('x')), D('padding:20px 24px', body)))) }
} else if (v === 'prof') {
shell.nav = 'profile'; shell.role = 'user'; shell.display = 'Pedro Lima'; shell.sideOpen = false; crumbs('Profile')
const R = ROLES.contrib
main = Page(m ? '16px' : '24px', Title('Profile', 'Your account.'), Card('Account', null, null, F('Name', 'Pedro Lima', {}), F('Email', 'pedro@renewbuild.example', { lock: true })), Card('Your role', null, null, D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', RB('Contributor'), Mu('Assigned by Marta Alves')), Mu(R.desc), D('display:flex;align-items:center;gap:6px;color:var(--ui-text-highlighted);font-weight:500', I('list-checks', 16), Link('See what I can do')), Mu('Your API keys follow this role. If your role changes, they change too.')))
if (k === 'sheet') { const body = [PvBody(R, { ro: true })]; ov.push(m ? PvSheet(R, { ro: true, title: 'What you can do', sub: 'This is a summary of your role.', foot: [B('Close', { full: true })] }) : Shell({ title: 'What you can do', desc: 'A summary of your role, Contributor. Read-only.', close: true, cancel: 'Close', top: 60 }, body)) }
} else if (v === 'keys') {
shell.nav = 'settings'; shell.sideOpen = true; crumbs('Settings', 'API keys'); const cols = 'minmax(0,1fr) 140px 130px 110px 40px'
main = Page(m ? '16px' : '24px', Title('API keys', 'Keys for apps that read or write your content.', B('New key', { k: 'primary', icon: 'plus', px: 14 })), Banner('info', 'info', 'Keys follow their creator\'s role', 'A key can do what its creator\'s role can do, today. If the person\'s role changes, their keys change with it.'), m ? D('display:flex;flex-direction:column;gap:8px', Cd({}, Hi('Website build'), Mu('Marta Alves · Owner')), Cd({}, Hi('Newsletter sync'), Mu('Pedro Lima · Contributor'))) : D('background:var(--ui-bg);border:1px solid var(--ui-border)', Th(cols, 'Name', 'Created by', 'Follows role', 'Created', ''), Tr(cols, {}, Hi('Website build'), Mu('Marta Alves'), RB('Owner'), Mu('Sep 2'), Dots()), Tr(cols, {}, Hi('Newsletter sync'), Mu('Pedro Lima'), RB('Contributor'), Mu('Oct 3'), Dots())))
}
if (m && shell.drawer === undefined && s.drawer) shell.drawer = true
const framed = true
return { framed, bare: false, shell, mob: m, main, ov: D('display:contents', ...ov), bw: '1440px', bh: '900px' }
}
}
</script>
</body>
</html>
+177
View File
@@ -0,0 +1,177 @@
const N = 'available now', L = 'later'
const st = (name, scope, trigger, s, copy) => ({ name, scope, trigger, s, copy })
const E = (name, k, trigger, copy, extra = {}) => st(name, N, trigger, { v: 'editor', k, ...extra }, copy)
const CONTRIB = 'Name Contributor · Description Creates content and edits only their own. · Content permissions · What this role can do with entries of each content type. Own means entries the person created. · Quick presets · Read only · Write own · Full content · Content type · Read · Create · Update · Delete · Publish · All content types · Sets every row · Articles · Built-in · Schema locked · None · Own · All · On · Off · General permissions · Everything outside entries. · Expand all'
export const groups = [
{ label: '1 Roles list', title: '1 Roles list', route: '/settings/roles', api: 'GET /v1/role', auth: 'Roles: view', note: 'Settings > Roles. Visible to anyone who can view roles. Owner carries a Locked badge. The row menu has Edit, Duplicate and Delete. Mobile: one card per role.', states: [
st('Default', N, 'Owner opens /settings/roles.', { v: 'list', k: 'ok' }, 'Roles · Who can do what on this site. Each user has one role. · New role · Role · Description · Members · Type · Owner · Locked · Admin · Writer · Contributor · Content lead · Built-in · Custom'),
st('Loading', N, 'First load or retry. Skeleton rows after 150 ms. New role disabled.', { v: 'list', k: 'loading' }, '(skeleton) · New role (disabled)'),
st('Only built-in roles', N, 'GET /v1/role returns the three built-ins and no custom role.', { v: 'list', k: 'empty' }, 'No custom roles yet · The three built-in roles cover most sites. Create a role when someone needs something in between. · New role'),
st('Row menu: custom role', N, 'Open the row menu on Contributor.', { v: 'list', k: 'menu' }, 'Edit · Duplicate · Delete'),
st('Row menu: built-in role', N, 'Open the row menu on Writer as owner. Delete is disabled and its tooltip shows on focus or hover.', { v: 'list', k: 'menub' }, 'Edit · Duplicate · Delete (disabled) · Built-in roles can\'t be deleted. Duplicate it to make a custom role.'),
st('No permission to edit', N, 'User has Roles: view but not Roles: manage. New role is disabled with a tooltip.', { v: 'list', k: 'noedit', role: 'admin' }, 'You can see roles but not change them · Ask an owner or admin if a role needs to change. · You can see roles, but only people who manage roles can create or change them.'),
st('Error', N, '500 or network failure.', { v: 'list', k: 'error' }, 'Couldn\'t load roles · GET /v1/role failed. Check your connection and try again. · Try again')
] },
{ label: '2 Role editor', title: '2 Role editor', route: '/settings/roles/new, /settings/roles/:id', api: 'POST /v1/role, GET/PUT /v1/role/{id}', auth: 'Roles: manage (view for read-only)', note: 'Name and description, then Content permissions (the grid moved here from /settings/permissions) and General permissions. Desktop: the plain-language panel sits on the right. Mobile: the grid is one card per type, and a sticky bar holds "What this role can do" and Save. The old route /settings/permissions redirects here (301 to /settings/roles).', states: [
E('New role (empty)', 'new', 'Owner or admin opens /settings/roles/new. Nothing is granted. Save is off until the role has a name.', 'New role · Name · For example, Contributor · Description (optional) · What this role is for · Name your role to save it · Discard · Save changes · ' + CONTRIB),
E('Editing a custom role', 'custom', 'Open Contributor. Two groups open. Case studies shows Custom with a Reset link.', CONTRIB + ' · Custom · Reset · 4 members · Custom'),
E('Built-in role, as owner', 'owner-edit', 'The owner opens Writer. A warning shows how many members are affected.', '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. · 2 unsaved changes'),
E('Built-in role, as admin (read-only)', 'admin-ro', 'An admin opens Writer. All controls are disabled and the fields are locked.', 'Only the owner can edit built-in roles · You can read what Writer allows. To change it, duplicate it into a custom role. · Duplicate role · Built-in'),
E('Owner role (locked)', 'locked', 'Anyone with Roles: view opens Owner.', 'The Owner role is locked · It always has every permission. To hand it to someone else, transfer ownership from Users. · Go to Users · Locked'),
E('General permissions', 'general', 'Scrolled to General permissions. Groups collapse; each shows "x of y enabled". Scoped rows use the same None / Own / All control.', 'Activity · See activity · Read the log of who did what. · Export activity · Download the log as a CSV file. · Users and invites · View users · Invite users · Change a user\'s role · Remove users · Settings · View settings · Edit general settings · Manage sites · See API keys · Create API keys · Revoke API keys · Delete a site · Owner only · 2 of 2 enabled'),
E('Owner-only items', 'owneronly', 'Roles and System groups, as an admin. Owner-only items are disabled with a lock and an Owner only tag. They stay off even for Admin.', 'Roles · View roles · See roles and what they allow. · Manage roles · Create, edit, duplicate and delete roles. · Edit roles with owner-only power · Change a role that carries owner-only abilities. · System · Apply software updates · Install a new version of RenewCMS. · Edit activity retention · How long the activity log is kept. · Transfer ownership · Make another member the owner. · Owner only'),
E('Unsaved changes', 'dirty', 'Three edits. Changed cells carry a square marker and the preview tags the changed line.', '3 unsaved changes · Discard · Save changes · Changed'),
E('Saving', 'saving', 'PUT in flight. Fields dim; Save shows the loader.', 'Saving… · Discard (disabled)'),
E('Saved', 'saved', '200. The bar closes and a toast confirms.', 'Role saved · Contributor is updated for 4 members.'),
E('Name required', 'req', 'Save with an empty name.', 'Give the role a name. · 1 problem to fix'),
E('Name already taken', 'taken', '409 role_name_taken on POST or PUT.', 'A role named "Writer" already exists. Choose a different name. · 1 problem to fix'),
E('Escalation blocked', 'esc', 'The editor holds Roles: manage but can publish only their own entries, so Publish: All is disabled with a lock.', 'You can only grant what your role has. Your role can publish only entries you created, so All isn\'t available. · All (locked)'),
E('Conflict', 'conflict', '409 role_conflict on PUT (version mismatch).', '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. · Reload role · Review differences · 2 unsaved changes. Save is off until you reload'),
E('Permission lowered while open (403)', 'lowered', '403 missing_permission on PUT or on refresh. 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. · Reload page')
] },
{ label: '3 Plain-language preview', title: '3 Plain-language preview', route: 'Panel inside the role editor; bottom sheet on mobile', api: 'None. Computed in the browser from the form', auth: 'Roles: view', note: 'Title "What this role can do". Two parts: Content (one sentence per group of types that share the same permissions) and Everything else (one sentence per group). A last line lists areas with no access. Lines that change while editing carry a Changed tag. Sentences are built from fixed templates, never from keys.', states: [
st('Live panel', N, 'Editing Contributor. Events: Update changed from Own to All. The first line is tagged.', { v: 'pv', k: 'live' }, 'What this role can do · Updates as you edit. · Content · All content types · Can read any entry, create entries and edit only their own. Can\'t delete and publish. · Everything else · 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. · No access to Activity, Users and invites, Settings, Roles. · Applies to 4 members, and to the API keys they created. · Changed'),
st('Read-only summary', N, 'Opened from a built-in role in read-only mode. No "Updates as you edit".', { v: 'pv', k: 'readonly', role: 'admin' }, 'What this role can do · A summary of what this role allows.'),
st('Nothing granted', N, 'New role with every permission off.', { v: 'pv', k: 'empty' }, 'Content · All content types · No access to entries. · No access to Schema, Media, Authors, Activity, Users and invites, Settings, Roles.'),
st('Mobile sheet', N, 'Tap "What this role can do" in the sticky bar. 92% sheet with grabber and close.', { v: 'editor', k: 'dirty', sheet: true }, 'What this role can do · Updates as you edit. · Close')
] },
{ label: '4 Delete role', title: '4 Delete role', route: 'Dialog over /settings/roles', api: 'POST /v1/role/{id}/reassign, DELETE /v1/role/{id}', auth: 'Roles: manage', note: 'A role with members is blocked until they move. Choosing a target role enables a single action that moves them and deletes the role. Roles with more power than the user are disabled in the picker with a reason.', states: [
st('Blocked: has members', N, 'Delete Contributor, 4 members.', { v: 'del', k: 'blocked' }, 'Delete Contributor? · 4 members have this role. Move them to another role first. Then Contributor can be deleted. · Move members to · Choose a role · They keep their API keys. Keys follow the new role. · Cancel · Move 4 members and delete role (disabled until a role is chosen)'),
st('Role picker open', N, 'The picker shows each role\'s summary. Admin is disabled for an admin.', { v: 'del', k: 'picker' }, 'Writer · Reads everything and writes content. Can\'t delete or publish. · Content lead · Edits and publishes all content. · Admin · Your role can\'t assign a role with more power than yours.'),
st('Confirm: no members', N, 'Delete Intern, 0 members.', { v: 'del', k: 'confirm' }, 'Delete Intern? · No one has this role. This can\'t be undone. · Deleting a role doesn\'t affect content. · Cancel · Delete role'),
st('Built-in role', N, 'Delete is disabled in the menu. If the dialog is reached by link it shows this state.', { v: 'del', k: 'builtin' }, '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. · Duplicate role · Close')
] },
{ label: '5 Users', title: '5 Users: roles and ownership', route: '/users', api: 'PUT /v1/user/{id}/role, POST /v1/invite, POST /v1/site/transfer-ownership', auth: 'Users: view; change role, invite, remove; owner for transfer', note: 'The Role column shows a role badge with an icon: crown (Owner), shield (Admin), pencil (Writer), person (custom). The owner row is protected. Pending invites show their role.', states: [
st('List with roles', N, 'Owner opens /users.', { v: 'users', k: 'list' }, 'Users · 5 members and invites · Invite user · Name · Email · Role · Status · Owner · Admin · Writer · Contributor · Content lead · Active · Invite pending'),
st('Row menu', N, 'Open the menu on Ines Duarte.', { v: 'users', k: 'menu' }, 'Change role · Send password reset · Remove user'),
st('Owner row menu', N, 'Owner row: Change role and Remove are disabled; Transfer ownership appears.', { v: 'users', k: 'ownermenu' }, 'Change role (disabled) · Transfer ownership · Remove user (disabled) · The owner can\'t be changed or removed. Transfer ownership first.'),
st('Change role dialog', N, 'Each option shows the role\'s one-line summary. The current role is marked.', { v: 'users', k: 'change' }, 'Change role for Ines Duarte · Her API keys follow the new role. · Admin · Writer · · Current · Contributor · Content lead · Cancel · Change role'),
st('Change role: options blocked', N, 'An admin opens the dialog. Roles with more power are disabled with a reason.', { v: 'users', k: 'changeblock' }, 'Owner · There is one owner. Transfer ownership to change who it is. · Content lead · It can publish. Your role can\'t grant that.'),
st('Invite dialog', N, 'Role picker defaults to Writer and shows its summary.', { v: 'users', k: 'invite' }, 'Invite user · They get an email with a link to set a password. · Email · Role · Writer · Writer is the default. You can change a role later. · Cancel · Send invite'),
st('Transfer ownership: pick member', N, 'Owner menu > Transfer ownership. Only members can be chosen.', { v: 'users', k: 'tr1' }, 'Transfer ownership · Choose who becomes the owner. Only members can be chosen. · Cancel · Continue'),
st('Transfer ownership: confirm', N, 'Consequences listed. The action stays off until the email is retyped exactly.', { v: 'users', k: 'tr2' }, 'Transfer ownership to Jonas Weber · This takes effect straight away. · Jonas Weber becomes the owner. · You become an Admin. You can\'t take ownership back unless he transfers it. · Only the owner can apply updates, delete a site and change activity retention. · Your API keys follow your new role. · Type jonas@renewbuild.example to confirm · The email must match exactly. · Back · Transfer ownership'),
st('Transfer ownership: done', N, 'The former owner is now Admin. The list shows the new owner.', { v: 'users', k: 'tr3' }, 'Ownership transferred · Jonas Weber is now the owner. You are an Admin.')
] },
{ label: '6 Role-aware shell', title: '6 Role-aware shell', route: 'Every page', api: 'GET /v1/me returns role and permission keys', auth: 'Any', note: 'Rule: hide whole areas the person can never use; disable, with a reason, an action that is blocked on something they can see; show read-only pages with a banner. Writer in these frames is the built-in Writer; Contributor shows "own" scope.', states: [
st('The rule', N, 'Reference card used across the app.', { v: 'shell', k: 'rule' }, 'Hidden · The person can never use it. · Disabled, with a reason · The person sees the thing but can\'t act on it. · Read-only · The person may look but not change.'),
st('Writer: Articles list', N, 'Create and edit all, no delete, no publish. No Schema, Users, Integration or Settings in the sidebar.', { v: 'shell', k: 'art' }, 'Articles · You can write and edit all articles. Publishing is done by an editor. · New article · Created by · Status · Draft · Published'),
st('Writer: row menu', N, 'Delete is disabled with a reason.', { v: 'shell', k: 'artmenu' }, 'Edit · Duplicate · Delete (disabled) · Your role can\'t delete articles.'),
st('Writer: entry editor', N, 'Publish is hidden. Status reads Draft with a hint.', { v: 'shell', k: 'entry' }, 'Save draft · Status · Draft · Saved at 11:02 · Ask an editor to publish.'),
st('Own scope: entry list', N, 'Contributor can edit only entries they created. Others show a View only mark.', { v: 'shell', k: 'own' }, 'Events · You can create entries and edit your own. · Entries by others are view only · Your role lets you edit entries you created. · New entry · View only'),
st('Own scope: row menu', N, 'On an entry by someone else, Edit and Delete are disabled.', { v: 'shell', k: 'ownmenu' }, 'Edit (disabled) · Duplicate · Delete (disabled) · You can only edit content you created.'),
st('Admin: read-only settings', N, 'An admin without Edit general settings. Fields locked, Save off.', { v: 'admin', k: 'ro' }, 'You can read these settings but not change them · Ask the owner to change them, or ask for a role that can edit settings. · Save changes (disabled)'),
st('Admin: owner-only settings', N, 'Software and Retention show an Owner only tag. The action stays visible and disabled.', { v: 'admin', k: 'owneronly' }, 'Owner only · Apply update (disabled) · Only the owner can apply updates. · Keep activity for · Admins can read the log. Only the owner can change how long it is kept.')
] },
{ label: '7 No access', title: '7 No access: 403 copy', route: 'Any page', api: '403 missing_permission, 403 role_locked', auth: 'Any', note: 'A direct link to a page the person can\'t use shows the page-level state with a link to a page they can use. A blocked action shows a toast.', states: [
st('Page: no access', N, 'Contributor opens /settings/roles by link.', { v: 'forbid', k: 'page' }, 'You don\'t have access to this page · Your role, Contributor, doesn\'t include it. Ask an owner or admin if you need it. · Go to Articles · See what I can do'),
st('Action: missing_permission', N, 'An action was refused with 403 missing_permission.', { v: 'forbid', k: 'missing' }, 'You don\'t have permission to do that · Your role doesn\'t include Delete entries. Ask an owner or admin.'),
st('Action: role_locked', N, 'An attempt to edit or delete the Owner role.', { v: 'forbid', k: 'locked' }, 'This role is locked · The Owner role can\'t be changed or deleted. Transfer ownership from Users instead.')
] },
{ label: '8 Activity', title: '8 Activity: role events', route: '/activity', api: 'GET /v1/activity', auth: 'Activity: own or all', note: 'New event types. The detail panel summarises a role diff as Added and Removed lines using the human permission names.', states: [
st('List with role events', N, 'Five new events.', { v: 'act', k: 'list' }, 'Role Contributor created · Role Contributor edited · Ines Duarte\'s role changed from Writer to Content lead · Ownership transferred from Marta Alves to Jonas Weber · Role Intern deleted'),
st('Detail: role diff', N, 'Open "Role Contributor edited".', { v: 'act', k: 'detail' }, 'Activity details · What changed · Added · Delete entries (Own) · Removed · Publish · 4 members have this role.')
] },
{ label: '9 Profile', title: '9 Profile: your role', route: '/profile', api: 'GET /v1/me', auth: 'Any', note: 'The person sees their own role and a read-only summary.', states: [
st('Role card', N, 'Any signed-in user.', { v: 'prof', k: 'card' }, 'Your role · Contributor · Assigned by Marta Alves · Creates content and edits only their own. · See what I can do · Your API keys follow this role. If your role changes, they change too.'),
st('See what I can do', N, 'Opens the summary. Dialog on desktop, sheet on mobile.', { v: 'prof', k: 'sheet' }, 'What you can do · A summary of your role, Contributor. Read-only. · Close')
] },
{ label: '10 API keys', title: '10 API keys: inherited role', route: '/settings/api-keys', api: 'GET /v1/api-key', auth: 'Settings: API keys', note: 'Keys follow their creator\'s role live. The fact is stated in an info alert and each row shows the creator\'s role.', states: [
st('Keys follow the role', N, 'Always shown on the API keys screen.', { v: 'keys', k: 'ok' }, 'Keys follow their creator\'s role · A key can do what its creator\'s role can do, today. If the person\'s role changes, their keys change with it. · Follows role')
] },
{ label: '11 Create a role in under a minute', title: '11 Flow: create "Contributor" in under a minute', route: '/settings/roles, /settings/roles/new', api: 'POST /v1/role', auth: 'Roles: manage', note: 'Six steps, about 40 seconds: 1 New role. 2 Name and description. 3 Choose the preset Write own. 4 Check the preview and the Schema and Media defaults. 5 Save. 6 Done. A new role starts with nothing granted; presets fill the grid in one tap.', states: [
st('1 Roles list: New role', N, 'Click New role.', { v: 'list', k: 'ok' }, 'New role'),
E('2 Name it', 'flow', 'Type a name and a one-line description.', 'Name Contributor · Description Writes posts and edits only their own', { flow: 2 }),
E('3 Tap the preset: Write own', 'flow', 'The preset sets Read: All, Create: On, Update: Own, Delete and Publish: None for every type.', 'Write own · 7 unsaved changes', { flow: 3 }),
E('4 Check General permissions', 'flow', 'Optional. Schema view and Media view and upload are on by default for the preset. Check the preview.', 'What this role can do · Can read any entry, create entries and edit only their own. Can\'t delete and publish.', { flow: 4 }),
E('5 Save', 'flow', 'Save changes. The role appears in the list and in the invite picker.', 'Save changes', { flow: 5 }),
E('6 Done', 'flow', 'Toast, and the bar closes.', 'Role created · Contributor is ready. Assign it from Users.', { flow: 6 })
] }
]
const T = (title, tpl, cols, rows) => ({ title, tpl, cols, rows })
export const tables = [
T('Permission catalogue (names shown to people)', '170px 1fr 1fr', ['Group', 'Items (human name)', 'Scope'], [
['Content, per type', 'Read entries · Create entries · Update entries · Delete entries · Publish entries', 'None / Own / All, except Create (on or off)'],
['Schema', 'View content types · Create and edit content types · Delete content types', 'On or off'],
['Media', 'View the media library · Upload files · Delete files', 'Delete: None / Own / All'],
['Authors', 'View authors · Create authors · Edit authors · Delete authors', 'Edit: None / Own / All'],
['Activity', 'See activity · Export activity', 'See: None / Own / All'],
['Users and invites', 'View users · Invite users · Change a user\'s role · Remove users', 'On or off'],
['Settings', 'View settings · Edit general settings · Manage sites · See API keys · Create API keys · Revoke API keys · Delete a site (owner only)', 'API keys: None / Own / All'],
['Roles', 'View roles · Manage roles · Edit roles with owner-only power (owner only)', 'On or off'],
['System (owner only)', 'Apply software updates · Edit activity retention · Transfer ownership', 'Locked off for everyone but the owner']]),
T('Built-in roles', '140px 1fr 160px', ['Role', 'What it has', 'Editable by'], [
['Owner', 'Everything. One per site. Ownership can be transferred; the former owner becomes Admin.', 'No one (locked)'],
['Admin', 'Everything except the owner-only list. Can see all settings, read-only where they can\'t edit.', 'Owner'],
['Writer', 'Reads schema and all content; creates and edits all content. No delete, no publish; no schema, settings, sites, users or integrations. Authors: view, edit own (assumed).', 'Owner (duplicate for variants)']]),
T('No-escalation feedback', '220px 1fr 1fr', ['Rule', 'Where it shows', 'Copy'], [
['Grant only what you hold', 'Disabled segment or switch with a lock; popover on focus or hover', 'You can only grant what your role has. Your role can publish only entries you created, so All isn\'t available.'],
['No role above yours', 'Disabled option in the role picker (Change role, Delete reassign, Invite)', 'Your role can\'t assign a role with more power than yours.'],
['No editing your own role', 'Editor opens read-only with a banner', 'You can\'t change the permissions of your own role. Ask another owner or admin.'],
['No delete while it has members', 'Delete dialog, blocked state', '4 members have this role. Move them to another role first. Then Contributor can be deleted.'],
['Owner-only list', 'Disabled item with an Owner only tag and a lock', 'Only the owner can apply updates.']]),
T('Hidden or disabled', '240px 1fr 1fr', ['Case', 'Treatment', 'Example'], [
['Whole area the role never has', 'Hidden (no sidebar item, no page card). Direct link shows the 403 page.', 'Schema for a Writer'],
['Action on something visible, blocked by role', 'Disabled with a reason in a tooltip or row text', 'Delete on an article for a Writer'],
['Action that only exists above their level', 'Hidden', 'Publish for a Writer; status shows Draft and "Ask an editor to publish."'],
['Row the person can read but not edit (Own)', 'View only mark on the row; Edit and Delete disabled in the menu', 'You can only edit content you created.'],
['Settings page they can read', 'Read-only: locked fields, banner, Save off', 'Admin on General without Edit general settings'],
['Owner-only action visible to an admin', 'Disabled, Owner only tag', 'Apply update']]),
T('HTTP status to copy (additions)', '180px 120px 1fr 1fr', ['Code', 'Status', 'Title', 'Body'], [
['missing_permission', '403', 'You don\'t have permission to do that', 'Your role doesn\'t include {permission name}. Ask an owner or admin.'],
['role_locked', '403', 'This role is locked', 'The Owner role can\'t be changed or deleted. Transfer ownership from Users instead.'],
['role_has_members', '409', 'Role still has members', '{n} members have this role. Move them to another role first.'],
['role_name_taken', '409', 'Name already used', 'A role named "{name}" already exists. Choose a different name.'],
['role_conflict', '409', 'This role changed while you were editing', '{name} saved changes to {role} at {time}. Reload to see them, or review how they differ from your edits.'],
['escalation', '403', 'You can\'t grant that', 'You can only grant what your role has.'],
['(page level)', '403', 'You don\'t have access to this page', 'Your role, {role}, doesn\'t include it. Ask an owner or admin if you need it.']]),
T('Components (new) and Nuxt UI v4 mapping', '200px 1fr 1fr', ['Component', 'Used for', 'Nuxt UI mapping'], [
['ScopeControl (tri-state)', 'None / Own / All in grid cells, mobile cards and general rows', 'URadioGroup variant="table" orientation="horizontal" size="xs", three items, in a grid cell. Selected item: sand fill, weight 600 and an underline'],
['OnOffCell', 'Create column and boolean rows', 'USwitch with visible On/Off label'],
['PermissionGrid', 'Content permissions', 'Custom grid (role="grid") of ScopeControl cells; header buttons use UDropdownMenu for bulk actions'],
['PresetPills', 'Read only, Write own, Full content', 'UButton variant="outline" size="xs", pressed state via aria-pressed'],
['PermissionGroup', 'Collapsible group with "x of y enabled"', 'UCollapsible; header UButton variant="ghost"'],
['OwnerOnlyTag', 'Locked owner-only items', 'UBadge variant="subtle" with lucide lock icon'],
['RoleBadge', 'Users, roles, API keys', 'UBadge variant="outline" with role icon (crown, shield, pencil, user-round)'],
['RolePreview', 'What this role can do', 'Card on desktop (UCard), USlideover side="bottom" on mobile'],
['RolePicker', 'Change role, invite, reassign on delete', 'URadioGroup variant="card" with description; USelectMenu for the reassign field; items disabled with a reason in description'],
['SaveBar', 'Unsaved changes', 'Sticky container with UButton color="neutral" variant="outline" and UButton color="primary"'],
['ReadOnlyBanner', 'Read-only pages', 'UAlert color="info" icon="i-lucide-lock" variant="soft"'],
['ViewOnlyMark', 'Own scope rows', 'Text with lucide lock icon and UTooltip']]),
T('Accessibility: tri-state cell', '220px 1fr', ['Item', 'Pattern'], [
['Structure', 'The grid is role="grid". Each cell contains a role="radiogroup" named "{Type}: {Action}" (for example "Events: Update") with three role="radio" items None, Own, All. Create is a role="switch" named "Events: Create".'],
['Keyboard', 'Tab moves between cells (one stop per cell). Inside a group, Left and Right (or Up and Down) move and select, Home and End jump to None and All. Arrow keys also move the roving tabindex. Space selects. Header bulk buttons open a menu with the same three values.'],
['Announcement', 'Selected item is aria-checked="true". A polite live region announces changes: "Events, Update, set to Own." Disabled items are aria-disabled and described by the reason ("You can only grant what your role has.").'],
['Not by color alone', 'Selected segment: sand fill plus weight 600 plus a 2 px underline in --ui-primary. Locked: lock icon. Changed cell: square marker. Mixed: text badge.'],
['Targets', '26 px segments on desktop, 44 px on mobile. Focus: 2 px --ui-primary outline, offset 2 px.']]),
T('Needs backend', '240px 1fr', ['Item', 'Detail'], [
['Role storage', 'Table role (id, name, description, built_in, locked, version) and role_permission (role_id, key, scope none|own|all). One role_id on user. Built-ins seeded and non-deletable.'],
['Endpoints', 'GET/POST /v1/role · GET/PUT/DELETE /v1/role/{id} · POST /v1/role/{id}/reassign · PUT /v1/user/{id}/role · POST /v1/site/transfer-ownership · GET /v1/me (role and permissions) · GET /v1/permission (catalogue)'],
['created_by', 'Add created_by (user id) to articles and entries so Own scope can be checked on read, update, delete and publish. Backfill from existing author or activity data.'],
['Enforcement', 'Server checks every call against role permissions and the no-escalation rules. API keys resolve the creator\'s current role on each request.'],
['Version field', 'role.version for the conflict state (409 role_conflict).'],
['Activity events', 'role.created, role.updated (with a diff of added and removed permissions), role.deleted, user.role_changed, site.ownership_transferred.']]),
T('Existing screens that change', '220px 1fr', ['Screen', 'Change'], [
['Sidebar and mobile drawer (AppFrame)', 'Items are listed per permission, not per fixed role. Writer sees Home, Articles, Media, Authors, Activity and Content. A Roles entry appears under Settings for people who can view roles.'],
['Settings navigation', 'New "Roles" item. "Permissions" is removed.'],
['/settings/permissions', 'Retired. 301 to /settings/roles. The grid lives in the role editor.'],
['Users (list, invite, row menu)', 'Role badges, Change role dialog, role picker in invite (default Writer), pending invites show the role, Transfer ownership, protected owner row.'],
['Articles list and Editor', 'Publish hidden for roles without it; status shows Draft with "Ask an editor to publish." Delete and Edit disabled with a reason when scope is Own and the item is someone else\'s.'],
['Content entry lists and editor', 'Same Own rules. View only mark.'],
['Settings pages', 'Read-only mode with banner for people who can view but not edit. Owner-only actions disabled with an Owner only tag.'],
['Schema', 'Hidden for roles without Schema view. Read-only for view-only roles.'],
['API keys', 'Info alert "Keys follow their creator\'s role", a Follows role column.'],
['Profile', 'Your role card and See what I can do.'],
['Activity and filters', 'New event types, role diff in the detail panel.'],
['Error and 403 copy', 'Two codes and the page-level state added.']]),
T('Decisions to confirm', '60px 1fr 1fr', ['#', 'Decision made', 'Alternative'], [
['1', 'Content permissions come before General permissions in the editor, because the common case (Contributor) only touches the grid.', 'General first, as in the brief.'],
['2', 'Writer gets Authors: view and edit own, and Activity: own.', 'No Authors or Activity access.'],
['3', 'A new role starts with nothing granted; presets fill the grid.', 'Start from a copy of Writer.'],
['4', 'Own-scope rows others created show "View only" with a lock icon, not a hidden row.', 'Hide other people\'s entries.'],
['5', 'Reset to default exists for built-in roles edited by the owner.', 'No reset.'],
['6', 'Activity own/all scope uses the same None / Own / All control.', 'Toggle plus a separate scope select.'],
['7', 'Transfer ownership takes effect at once, no email to the new owner.', 'The new owner must accept.'],
['8', 'A user with Roles: manage cannot edit their own role, even in a custom role.', 'Allow narrowing only.']])
]
+34
View File
@@ -107,3 +107,37 @@ Entry rules, 422 except unique (409): required, type, min, max, options, unique,
## Media field (pass 5) ## Media field (pass 5)
Copy and states are in `media-field-changes.md` (sections 2 to 6). Entry rules for a media field: required 422 "Pick an image for Thumbnail."; type 422 "Thumbnail must be an image from the library."; min, max, options and unique do not apply. A required media field added to a type with entries is refused client side in the review ("Can't be applied") and by the server as 409 schema_incompatible. Image deleted while entries use it: the control shows "This image is no longer in the library"; saving is refused only when the field is required. HTTP statuses for the picker and the field use the table above (400, 401, 403, 404, 409, 413, 429, 500, network). Copy and states are in `media-field-changes.md` (sections 2 to 6). Entry rules for a media field: required 422 "Pick an image for Thumbnail."; type 422 "Thumbnail must be an image from the library."; min, max, options and unique do not apply. A required media field added to a type with entries is refused client side in the review ("Can't be applied") and by the server as 409 schema_incompatible. Image deleted while entries use it: the control shows "This image is no longer in the library"; saving is refused only when the field is required. HTTP statuses for the picker and the field use the table above (400, 401, 403, 404, 409, 413, 429, 500, network).
## Roles and permissions
61 states, all Available now, listed with trigger and copy in `screens/roles.md`. Groups: Roles list 7, Role editor 15, Plain-language preview 4, Delete role 4, Users 9, Role-aware shell 8, No access 3, Activity 2, Profile 2, API keys 1, Create-a-role flow 6. None is clock-sensitive (times are fixed strings).
Hidden or disabled:
| Case | Treatment | Example |
|---|---|---|
| Whole area the role never has | Hidden (no sidebar item, no page card). Direct link shows the 403 page. | Schema for a Writer |
| Action on something visible, blocked by role | Disabled with a reason in a tooltip or row text | Delete on an article for a Writer |
| Action that only exists above their level | Hidden | Publish for a Writer; status shows Draft and "Ask an editor to publish." |
| Row the person can read but not edit (Own) | View only mark on the row; Edit and Delete disabled in the menu | You can only edit content you created. |
| Settings page they can read | Read-only: locked fields, banner, Save off | Admin on General without Edit general settings |
| Owner-only action visible to an admin | Disabled, Owner only tag | Apply update |
No-escalation feedback:
| Rule | Where it shows | Copy |
|---|---|---|
| Grant only what you hold | Disabled segment or switch with a lock; popover on focus or hover | You can only grant what your role has. Your role can publish only entries you created, so All isn't available. |
| No role above yours | Disabled option in the role picker (Change role, Delete reassign, Invite) | Your role can't assign a role with more power than yours. |
| No editing your own role | Editor opens read-only with a banner | You can't change the permissions of your own role. Ask another owner or admin. |
| No delete while it has members | Delete dialog, blocked state | 4 members have this role. Move them to another role first. Then Contributor can be deleted. |
| Owner-only list | Disabled item with an Owner only tag and a lock | Only the owner can apply updates. |
HTTP status to copy (additions):
| Code | Status | Title | Body |
|---|---|---|---|
| missing_permission | 403 | You don't have permission to do that | Your role doesn't include {permission name}. Ask an owner or admin. |
| role_locked | 403 | This role is locked | The Owner role can't be changed or deleted. Transfer ownership from Users instead. |
| role_has_members | 409 | Role still has members | {n} members have this role. Move them to another role first. |
| role_name_taken | 409 | Name already used | A role named "{name}" already exists. Choose a different name. |
| role_conflict | 409 | This role changed while you were editing | {name} saved changes to {role} at {time}. Reload to see them, or review how they differ from your edits. |
| escalation | 403 | You can't grant that | You can only grant what your role has. |
| (page level) | 403 | You don't have access to this page | Your role, {role}, doesn't include it. Ask an owner or admin if you need it. |