feat: rework the global permissions + make manage:navigation site and path aware instead of global

pull/8104/head
NGPixel 2 weeks ago
parent 7185f468f6
commit 76845ab462
No known key found for this signature in database

@ -390,12 +390,68 @@ kind a name belongs to decides how it may be enforced, so it is the first thing
any permission you touch. any permission you touch.
**Global permissions** are held site-wide, bound to no path: `access:admin`, `read:users`, **Global permissions** are held site-wide, bound to no path: `access:admin`, `read:users`,
`manage:users`, `read:groups`, `manage:groups`, `read:audit`, `read:metrics`, `write:users`, `manage:users`, `read:groups`, `write:groups`, `manage:groups`, `read:audit`,
`manage:navigation`, `manage:theme`, `manage:sites`, `manage:system`. That is the list as it stands — the one offered by the group editor `read:metrics`, `manage:theme`, `manage:storage`, `manage:sites`,
`read:webhooks`, `manage:webhooks`, `manage:system`. That is the
list as it stands — the one offered by the group editor
(`GroupEditOverlay.vue`). They live on a group's `permissions` column, are flattened onto (`GroupEditOverlay.vue`). They live on a group's `permissions` column, are flattened onto
`req.session.permissions` at login (`models/users.ts` → `updateSession`), and are what the per-route `req.session.permissions` at login (`models/users.ts` → `updateSession`), and are what the per-route
`config.permissions` hook checks. `manage:system` bypasses every check everywhere. `config.permissions` hook checks. `manage:system` bypasses every check everywhere.
**Five of them are ELEVATED ADMIN PERMISSIONS**: `write:users`, `manage:users`, `write:groups`,
`manage:groups` and `manage:system`. `ELEVATED_PERMISSIONS` in `models/groups.ts` is the list that
decides, `isElevated()` is the test, and `groups.elevatedGroupIds()` answers which groups carry one.
What makes them a category is that each is a route to every OTHER permission on the wiki: whoever
can rewrite who holds what can grant themselves anything, in one step or two. So **membership of a
group carrying one is itself a privilege**, and the guards are written against the whole list rather
than against `manage:system` alone — stopping at the root permission would leave `manage:users`
handing out `manage:groups`, and `manage:groups` handing back `manage:users`, with neither step
looking like an escalation on its own.
Three rules follow, and they are enforced in `api/users.ts` and `api/groups.ts` rather than in the
models, since they are questions about the CALLER:
- **Nobody but `manage:system` moves a user in or out of an elevated group** — on create as well as
on edit, and in both directions. Creating an account already inside one is the same act as
promoting an existing one.
- **`manage:users` may not touch an account that belongs to a `manage:system` group at all**
(`systemUserGuard`). That guard is `manage:system` only, not the whole list: a `manage:groups`
account is protected from being re-grouped, not from being renamed.
- **The two group-editing rungs stop at different places** (`elevatedGroupGuard`): `manage:groups` is
stopped only by `manage:system`, `write:groups` by any of the five.
The list is exposed to clients as a single `isElevated` boolean on `GroupCore`, never as the
permissions themselves — a caller who may not read a group still has to know which ones its controls
must not offer.
**A site's settings are split across three permissions that do not overlap**, so that the look of a
site, where its content is kept, and everything else about it are three separate grants:
| Permission | Admin screens |
| ---------- | ------------- |
| `manage:sites` | General, Analytics, Approvals, Comments, Content Blocks, Editors, Locale, Login — plus creating, deleting and listing sites |
| `manage:theme` | Theme, and nothing else. `PUT /sites/:siteId/theme` takes this alone |
| `manage:storage` | Storage, and nothing else. Every route in `api/storage.ts` takes this alone |
An administrator who is to change all of a site's settings therefore holds all three.
**Webhooks are their own pair**, `read:webhooks` and `manage:webhooks` (`api/hooks.ts`), rather than
part of `manage:system` as they were. A webhook's `authHeader` is sent verbatim as the
`Authorization` header of every delivery, so it is a credential for somebody else's service: it
reads back as `SENSITIVE_MASK` for a caller who may not change webhooks, and the mask posted back
means "unchanged", the same contract module props use. Whoever may edit still sees the value — the
field is theirs to correct. Don't "helpfully"
accept `manage:sites` on a theme or storage route — the disjointness is the point, and the general
site update (`PUT /sites/:siteId`) refuses a `theme` key for the same reason.
**The two `write:*` rungs sit between reading and managing:**
| Permission | May | May not |
| ---------- | --- | ------- |
| `write:users` | create an account | change any existing one; see the list (that is `read:users`); create into an elevated group |
| `write:groups` | create a group, rename it, write its page rules, staff an ordinary one | change what a group is ALLOWED to do (the Permissions tab); delete a group; staff an elevated one |
**Adding a global permission is the maintainer's call, not yours.** The list is not frozen, but a new **Adding a global permission is the maintainer's call, not yours.** The list is not frozen, but a new
name reshapes who can do what across the whole instance and every existing group silently lacks it — name reshapes who can do what across the whole instance and every existing group silently lacks it —
so propose it and wait for a yes before writing any code that names it. Until then, express what a so propose it and wait for a yes before writing any code that names it. Until then, express what a
@ -405,7 +461,7 @@ that is already on it needs no permission from anybody.
**Page rule permissions** are bound to paths, and to locales and sites: `read:pages`, `write:pages`, **Page rule permissions** are bound to paths, and to locales and sites: `read:pages`, `write:pages`,
`review:pages`, `manage:pages`, `delete:pages`, `write:styles`, `write:scripts`, `read:source`, `review:pages`, `manage:pages`, `delete:pages`, `write:styles`, `write:scripts`, `read:source`,
`read:history`, `read:assets`, `write:assets`, `manage:assets`, `read:comments`, `write:comments`, `read:history`, `read:assets`, `write:assets`, `manage:assets`, `read:comments`, `write:comments`,
`manage:comments` (`PAGE_PERMISSIONS` in `api/pages.ts`). A group grants them through **rules**: `manage:comments`, `manage:navigation` (`PAGE_PERMISSIONS` in `api/pages.ts`). A group grants them through **rules**:
each rule names some of them (`roles`) plus how it addresses pages (`match` + `path`, or tags) and each rule names some of them (`roles`) plus how it addresses pages (`match` + `path`, or tags) and
what it does with them (`mode`: ALLOW / DENY / FORCEALLOW). Nothing is granted by default, and when what it does with them (`mode`: ALLOW / DENY / FORCEALLOW). Nothing is granted by default, and when
several rules match, the most specific one wins — `helpers/pageRules.ts` documents the ordering. several rules match, the most specific one wins — `helpers/pageRules.ts` documents the ordering.
@ -426,6 +482,16 @@ Consequences worth knowing:
treats `manage:system` as a wildcard, so it answers "may do this somewhere". Gate a control over treats `manage:system` as a wildcard, so it answers "may do this somewhere". Gate a control over
the page in front of the reader on `pagePermissions` — that is what the endpoint behind the the page in front of the reader on `pagePermissions` — that is what the endpoint behind the
button will check. button will check.
- **`manage:navigation` asks about TWO paths.** It is the one page permission where holding it at the
page in front of you is not the whole answer. At the page it buys the navigation MODE — whether
this page inherits, overrides or hides its sidebar — which affects nothing above it. Editing the
menu's ITEMS additionally needs it on the entry the menu BELONGS to, since those items are shown to
every page under that entry: a page that inherits is editing its ancestor's menu, and a page with
no overriding ancestor is editing the site-wide one, which `navigation.menuOwnerRef` reports as the
home page's path. So a rule over `/guides` lets that section re-point its own pages without letting
it rewrite the menu handed down to it. `api/navigation.ts` has the pair of checks
(`mayManageNavAt`, `mayEditNavItems`), and the `inherited` route answers `canEditItems` so the
editor knows which of its two halves to offer.
- **An anonymous request is the guests group**, not an absence of groups: that is how a wiki opens - **An anonymous request is the guests group**, not an absence of groups: that is how a wiki opens
reading, and suggesting edits, to the public. Deny guests explicitly where an account is genuinely reading, and suggesting edits, to the public. Deny guests explicitly where an account is genuinely
required (`reviewerFor` in `api/approvals.ts` is the worked example). required (`reviewerFor` in `api/approvals.ts` is the worked example).
@ -784,7 +850,7 @@ store; no SVG is ever written into content.
Components that take an `icon` prop go through it too, so every form works there. Components that take an `icon` prop go through it too, so every form works there.
- Every Iconify reference written **literally in this repo's source** is inlined at build time by - Every Iconify reference written **literally in this repo's source** is inlined at build time by
`scripts/generate-icons.mjs` into `src/assets/icons.generated.js` (committed) and drawn as an `scripts/generate-icons.mjs` into `src/assets/icons.generated.js` (committed) and drawn as an
inline `<svg>`. Run `npm run icons` after adding or removing one; `check-icons.mjs` fails if the inline `<svg>`. Run `npm run icons` after adding or removing one; `npm run icons:check` fails if the
bundle drifts. This is why the interface needs no icon webfont — and why nothing an bundle drifts. This is why the interface needs no icon webfont — and why nothing an
administrator does to icon sets can blank it, which fetching at runtime could not promise: administrator does to icon sets can blank it, which fetching at runtime could not promise:
resolution is gated on the set being enabled, and deleting a set drops every icon stored for it. resolution is gated on the set being enabled, and deleting a set drops every icon stored for it.
@ -1121,14 +1187,13 @@ An earlier iteration of 3.x used GraphQL/Apollo. **All of it is deprecated** —
server left in `backend/`, and `APOLLO_CLIENT` is not defined as a global, so any call still going server left in `backend/`, and `APOLLO_CLIENT` is not defined as a global, so any call still going
through it throws. through it throws.
**One call is left.** `pages/AdminNavigation.vue`'s `save()` sends the navigation tree and its mode **Nothing calls it any more.** The last one was `pages/AdminNavigation.vue`, an admin screen that was
through `APOLLO_CLIENT.mutate`, so saving the navigation is broken until it is ported. Nothing else already experimental, disabled in the nav and broken twice over (its `save()` went through
under `frontend/src/` references the global. That handler needs more than the endpoint, mind: it also `APOLLO_CLIENT.mutate`, and it called `this.$store.commit(...)` nine times in a `<script setup>` file
calls `this.$store.commit(...)` nine times over, and the file is `<script setup>` with no Vuex store with no Vuex store anywhere in the app). It was deleted along with its route when `manage:navigation`
anywhere in the app — so `this` is undefined and every one of those throws too. became a page rule — navigation is edited from the sidebar of the page it belongs to, through
`api/navigation.ts`. `grep APOLLO_CLIENT frontend/src` now finds nothing.
When touching it, port it to the REST API (`API_CLIENT` + the matching `backend/api/` route)
rather than extending the GraphQL code. If the REST endpoint doesn't exist yet, add it under If you find yourself wanting a GraphQL endpoint, add a REST one under `backend/api/` following the
`backend/api/` following the schema + permissions conventions above — `sites/:siteId/images/:kind`, schema + permissions conventions above instead — `sites/:siteId/images/:kind`, which replaced the logo
which replaced the logo and favicon upload mutations in `AdminGeneral.vue`, is a recent example of and favicon upload mutations in `AdminGeneral.vue`, is an example of doing exactly that.
doing exactly that.

@ -1,28 +1,40 @@
import { audit } from '../helpers/audit.ts' import { audit } from '../helpers/audit.ts'
import { CustomError } from '../helpers/common.ts' import { CustomError } from '../helpers/common.ts'
import { SYSTEM_PERMISSION } from '../models/groups.ts' import { ELEVATED_PERMISSIONS, SYSTEM_PERMISSION, isElevated } from '../models/groups.ts'
import type { FastifyInstance, FastifyRequest } from 'fastify' import type { FastifyInstance, FastifyRequest } from 'fastify'
import type { GroupPatch, GroupRule, GroupWithUserCount } from '../models/groups.ts' import type { GroupPatch, GroupRule, GroupWithUserCount } from '../models/groups.ts'
/** /**
* Refuse a `manage:groups` holder any change to who is in a group that carries `manage:system`. * Refuse a change to who is in a group that administers the instance.
* *
* Membership of such a group IS the permission: adding somebody hands them the root of the instance, * Membership of such a group IS the permission: adding somebody hands them what the group can reach,
* and removing somebody takes it away from a real administrator. Deleting the group does both at * and removing somebody takes it away from a real administrator. Deleting the group does both at
* once, so it asks the same question. * once, so it asks the same question.
* *
* Where the line falls depends on what the caller holds, and the two rungs are deliberately
* different:
*
* - **`manage:groups`** is stopped only by `manage:system`, the permission that bypasses every check
* on the server. Everything below that is theirs to arrange; managing groups is the job.
* - **`write:groups`** is stopped by every one of `ELEVATED_PERMISSIONS`. It is the rung that may
* build and populate ordinary groups without being trusted to decide who administers the wiki —
* and since it cannot edit a group's permissions at all, its only route to an elevated group would
* be through the membership of one that already exists.
*
* @param action What the caller was trying to do, as the message reads it back to them * @param action What the caller was trying to do, as the message reads it back to them
* @returns The refusal to throw, or null when the caller may proceed * @returns The refusal to throw, or null when the caller may proceed
*/ */
function systemGroupGuard( function elevatedGroupGuard(
req: FastifyRequest, req: FastifyRequest,
group: GroupWithUserCount, group: GroupWithUserCount,
action = 'change who belongs to the group' action = 'change who belongs to the group'
): CustomError | null { ): CustomError | null {
if (!group.permissions.includes(SYSTEM_PERMISSION)) { if (WIKI.models.groups.holdsSystemPermission(req)) {
return null return null
} }
if (WIKI.models.groups.holdsSystemPermission(req)) { const permissions = req.apiKey?.permissions ?? req.session?.permissions ?? []
if (permissions.includes('manage:groups')) {
if (!group.permissions.includes(SYSTEM_PERMISSION)) {
return null return null
} }
return new CustomError( return new CustomError(
@ -30,6 +42,18 @@ function systemGroupGuard(
`This group has the ${SYSTEM_PERMISSION} permission. Only a user who holds it can ${action}.`, `This group has the ${SYSTEM_PERMISSION} permission. Only a user who holds it can ${action}.`,
403 403
) )
}
if (!isElevated(group.permissions)) {
return null
}
const held = group.permissions.filter((p) =>
(ELEVATED_PERMISSIONS as readonly string[]).includes(p)
)
return new CustomError(
'groupMembershipElevatedProtected',
`This group administers the wiki (${held.join(', ')}). Only a user who holds manage:groups or manage:system can ${action}.`,
403
)
} }
interface GroupUpdateBody { interface GroupUpdateBody {
@ -51,20 +75,12 @@ async function routes(app: FastifyInstance) {
app.get( app.get(
'/', '/',
{ {
config: {
/* /*
`manage:navigation` is here because a menu item can be limited to groups, and `manage:sites` No route-level `permissions`: most of the callers are global-permission holders and the hook
because an approval rule names the groups that may suggest an edit and the groups that could answer for them, but `manage:navigation` is a PAGE RULE now and the hook reads the
review one — both editors have to be able to name a group they cannot otherwise read, and group-wide list only. Both kinds are checked in the handler instead, so the answer is the
the approvals screen loads this alongside its rules, so without it the screen does not open same for everyone who needs it.
at all.
Safe to grant on this route and this route only: the listing is `GroupCore`, which carries
no permissions, no rules and no members — reading one group in full, or its members, keeps
needing `manage:groups`.
*/ */
permissions: ['read:groups', 'manage:groups', 'manage:navigation', 'manage:sites']
},
schema: { schema: {
summary: 'List all groups', summary: 'List all groups',
description: description:
@ -79,8 +95,45 @@ async function routes(app: FastifyInstance) {
} }
} }
}, },
async () => { async (req, reply) => {
return WIKI.models.groups.getAllGroups() /*
Everything that has to NAME a group without being able to read one.
The global half: `manage:sites` because an approval rule names the groups that may suggest an
edit and the groups that review one; `read:users` and `manage:users` because the user editor
shows which groups an account belongs to; the group permissions themselves.
The page-rule half: `manage:navigation`, because a menu item can be limited to groups and the
navigation editor has to offer them. Asked as "anywhere" rather than against a path, since
this request names no page — see `groups.grantsAnywhere`.
Safe on this route and this route only: the listing is `GroupCore`, which carries no
permissions, no rules and no members. Reading one group in full, or its members, keeps needing
`read:groups`, and changing one keeps needing `manage:groups`.
*/
const actor = WIKI.models.groups.actorForRequest(req)
const allowed =
[
'read:groups',
'write:groups',
'manage:groups',
'manage:sites',
'read:users',
'manage:users'
].some((permission) => actor.permissions.includes(permission)) ||
WIKI.models.groups.grantsAnywhere(actor, 'manage:navigation')
if (!allowed) {
return reply.forbidden('You are not allowed to list groups.')
}
/*
`isElevated` rather than the permissions themselves: this listing is deliberately thin and is
granted to callers who may not read a group in full, but every one of those callers has a
control to draw that must not offer a group the server will refuse.
*/
return (await WIKI.models.groups.getAllGroups()).map((group) => ({
...group,
isElevated: isElevated(group.permissions ?? [])
}))
} }
) )
@ -160,7 +213,7 @@ async function routes(app: FastifyInstance) {
'/:groupId', '/:groupId',
{ {
config: { config: {
permissions: ['read:groups', 'manage:groups'] permissions: ['read:groups', 'write:groups', 'manage:groups']
}, },
schema: { schema: {
summary: 'Get a single group', summary: 'Get a single group',
@ -328,11 +381,33 @@ async function routes(app: FastifyInstance) {
} }
/* /*
A `manage:groups` holder may edit a group that carries `manage:system` -- name, rules, Who may rewrite the global permission list of a group, which is the question the Permissions
redirects, every other permission -- but may not turn that one permission on or off. Granting tab asks and the one thing separating the two group-editing rungs.
it is handing over the instance; revoking it is locking the real administrators out.
`write:groups` may not touch it at all: it creates and arranges groups, names them, writes
their page rules and moves people in and out of the ordinary ones -- but what a group is
ALLOWED to do instance-wide is not its to decide, since granting `manage:users` to a group it
belongs to would be a way of granting itself anything. The list is compared rather than
merely refused when present, so a client that round-trips the whole group back unchanged --
which is exactly what the editor does on every save -- still saves the fields it may.
`manage:groups` may rewrite the list, except for `manage:system` itself: granting that hands
over the instance, and revoking it locks the real administrators out.
*/ */
if (patch.permissions && !WIKI.models.groups.holdsSystemPermission(req)) { if (patch.permissions && !WIKI.models.groups.holdsSystemPermission(req)) {
const callerPermissions = req.apiKey?.permissions ?? req.session?.permissions ?? []
const unchanged =
group.permissions.length === patch.permissions.length &&
group.permissions.every((permission) => patch.permissions!.includes(permission))
if (!callerPermissions.includes('manage:groups')) {
if (!unchanged) {
throw new CustomError(
'groupUpdatePermissionsForbidden',
'Only a user who holds manage:groups or manage:system can change what a group is allowed to do. Every other change to this group is allowed.',
403
)
}
} else {
const held = group.permissions.includes(SYSTEM_PERMISSION) const held = group.permissions.includes(SYSTEM_PERMISSION)
if (held !== patch.permissions.includes(SYSTEM_PERMISSION)) { if (held !== patch.permissions.includes(SYSTEM_PERMISSION)) {
throw new CustomError( throw new CustomError(
@ -342,6 +417,7 @@ async function routes(app: FastifyInstance) {
) )
} }
} }
}
// -> Rule IDs must be unique within the group, as they address the rule client-side // -> Rule IDs must be unique within the group, as they address the rule client-side
if (patch.rules) { if (patch.rules) {
@ -418,7 +494,7 @@ async function routes(app: FastifyInstance) {
} }
// -> Deleting the group removes every member from it, so it is the membership guard's question // -> Deleting the group removes every member from it, so it is the membership guard's question
const systemGroupRefusal = systemGroupGuard(req, group, 'delete the group') const systemGroupRefusal = elevatedGroupGuard(req, group, 'delete the group')
if (systemGroupRefusal) { if (systemGroupRefusal) {
throw systemGroupRefusal throw systemGroupRefusal
} }
@ -446,7 +522,7 @@ async function routes(app: FastifyInstance) {
'/:groupId/users', '/:groupId/users',
{ {
config: { config: {
permissions: ['read:groups', 'manage:groups'] permissions: ['read:groups', 'write:groups', 'manage:groups']
}, },
schema: { schema: {
summary: 'List the users assigned to a group', summary: 'List the users assigned to a group',
@ -563,7 +639,7 @@ async function routes(app: FastifyInstance) {
return reply.notFound('User does not exist.') return reply.notFound('User does not exist.')
} }
const systemGroupRefusal = systemGroupGuard(req, group) const systemGroupRefusal = elevatedGroupGuard(req, group)
if (systemGroupRefusal) { if (systemGroupRefusal) {
throw systemGroupRefusal throw systemGroupRefusal
} }
@ -643,7 +719,7 @@ async function routes(app: FastifyInstance) {
return reply.notFound('User is not assigned to this group.') return reply.notFound('User is not assigned to this group.')
} }
const systemGroupRefusal = systemGroupGuard(req, group) const systemGroupRefusal = elevatedGroupGuard(req, group)
if (systemGroupRefusal) { if (systemGroupRefusal) {
throw systemGroupRefusal throw systemGroupRefusal
} }

@ -1,7 +1,33 @@
import { audit } from '../helpers/audit.ts' import { audit } from '../helpers/audit.ts'
import type { FastifyInstance } from 'fastify' import { SENSITIVE_MASK } from '../helpers/common.ts'
import type { FastifyInstance, FastifyRequest } from 'fastify'
import { EMITTED_EVENTS, HOOK_EVENTS } from '../models/hooks.ts' import { EMITTED_EVENTS, HOOK_EVENTS } from '../models/hooks.ts'
/** Whether this caller may change webhooks, as opposed to only reading them. */
function mayManage(req: FastifyRequest): boolean {
const permissions = req.apiKey?.permissions ?? req.session?.permissions ?? []
return permissions.includes('manage:webhooks') || permissions.includes('manage:system')
}
/**
* A webhook as this caller may read it.
*
* `authHeader` is sent verbatim as the `Authorization` header of every delivery, so it is a
* credential for somebody else's service rather than a setting -- and `read:webhooks` exists to let
* somebody see what this wiki is wired to without handing them the keys to it. Masked the way a
* module's `sensitive` prop is, and for the reason given there: the value would otherwise end up in
* a browser, a cache and a screen share.
*
* Left intact for whoever may edit the webhook, since the field is theirs to read back and correct.
* That asymmetry is the whole point of the read-only rung.
*/
function forReader(req: FastifyRequest, hook: Record<string, any>): Record<string, any> {
if (mayManage(req)) {
return hook
}
return { ...hook, authHeader: hook.authHeader ? SENSITIVE_MASK : hook.authHeader }
}
interface HookBody { interface HookBody {
name?: string name?: string
events?: string[] events?: string[]
@ -50,10 +76,12 @@ async function routes(app: FastifyInstance) {
'/', '/',
{ {
config: { config: {
permissions: ['manage:system'] permissions: ['read:webhooks', 'manage:webhooks']
}, },
schema: { schema: {
summary: 'List all webhooks', summary: 'List all webhooks',
description:
'Every webhook and its settings. `authHeader` reads as a fixed mask for a caller who may not change webhooks -- it is a credential for the service at the other end, not a setting to be read.',
tags: ['Webhooks'], tags: ['Webhooks'],
response: { response: {
200: { 200: {
@ -64,8 +92,8 @@ async function routes(app: FastifyInstance) {
} }
} }
}, },
async () => { async (req) => {
return WIKI.models.hooks.getHooks() return (await WIKI.models.hooks.getHooks()).map((hook) => forReader(req, hook))
} }
) )
@ -76,7 +104,7 @@ async function routes(app: FastifyInstance) {
'/events', '/events',
{ {
config: { config: {
permissions: ['manage:system'] permissions: ['read:webhooks', 'manage:webhooks']
}, },
schema: { schema: {
summary: 'List the events a webhook can subscribe to', summary: 'List the events a webhook can subscribe to',
@ -115,10 +143,12 @@ async function routes(app: FastifyInstance) {
'/:hookId', '/:hookId',
{ {
config: { config: {
permissions: ['manage:system'] permissions: ['read:webhooks', 'manage:webhooks']
}, },
schema: { schema: {
summary: 'Get a single webhook', summary: 'Get a single webhook',
description:
'See the listing for how `authHeader` reads.',
tags: ['Webhooks'], tags: ['Webhooks'],
params: { params: {
type: 'object', type: 'object',
@ -140,7 +170,7 @@ async function routes(app: FastifyInstance) {
if (!hook) { if (!hook) {
return reply.notFound('Webhook does not exist.') return reply.notFound('Webhook does not exist.')
} }
return hook return forReader(req, hook)
} }
) )
@ -151,7 +181,7 @@ async function routes(app: FastifyInstance) {
'/', '/',
{ {
config: { config: {
permissions: ['manage:system'] permissions: ['manage:webhooks']
}, },
schema: { schema: {
summary: 'Create a new webhook', summary: 'Create a new webhook',
@ -219,7 +249,7 @@ async function routes(app: FastifyInstance) {
'/:hookId', '/:hookId',
{ {
config: { config: {
permissions: ['manage:system'] permissions: ['manage:webhooks']
}, },
schema: { schema: {
summary: 'Update a webhook', summary: 'Update a webhook',
@ -275,6 +305,15 @@ async function routes(app: FastifyInstance) {
patch[field] = req.body[field] patch[field] = req.body[field]
} }
} }
/*
The mask means "unchanged", exactly as it does for a module's sensitive props: a client that
read a masked `authHeader` and posts the whole webhook back must not store a row of dots as
the credential. An empty string is not the mask and does clear it, which is how the header is
removed.
*/
if (patch.authHeader === SENSITIVE_MASK) {
delete patch.authHeader
}
if (Object.keys(patch).length < 1) { if (Object.keys(patch).length < 1) {
return reply.badRequest('No webhook fields provided to update.') return reply.badRequest('No webhook fields provided to update.')
} }
@ -302,7 +341,7 @@ async function routes(app: FastifyInstance) {
'/:hookId', '/:hookId',
{ {
config: { config: {
permissions: ['manage:system'] permissions: ['manage:webhooks']
}, },
schema: { schema: {
summary: 'Delete a webhook', summary: 'Delete a webhook',

@ -1,7 +1,59 @@
import { audit } from '../helpers/audit.ts' import { audit } from '../helpers/audit.ts'
import { mayOnPage } from './pages.ts'
import type { FastifyInstance, FastifyRequest } from 'fastify' import type { FastifyInstance, FastifyRequest } from 'fastify'
import { NAVIGATION_MODES, type NavigationItem, type NavigationMode } from '../models/navigation.ts' import { NAVIGATION_MODES, type NavigationItem, type NavigationMode } from '../models/navigation.ts'
/**
* Whether this requester may manage the navigation of the page at `pageId`.
*
* `manage:navigation` is a PAGE RULE, so it is a question about a path and is asked of the page the
* caller is standing on. What it buys there is the MODE — whether this page inherits, overrides or
* hides its sidebar — which is a property of the page and affects nothing above it.
*
* @returns The page as the rules see it, or null when there is no such page or no permission
*/
async function mayManageNavAt(
req: FastifyRequest,
siteId: string,
pageId: string
): Promise<boolean> {
const page = await WIKI.models.pages.getPage({ siteId, id: pageId })
if (!page) {
return false
}
return mayOnPage(req, 'manage:navigation', page)
}
/**
* Whether this requester may edit the ITEMS of the menu the page at `pageId` shows.
*
* Two permissions, not one, and the second is the point of the whole arrangement: the items belong
* to whichever entry OWNS the menu, which for a page that inherits is an ancestor — so editing them
* from here changes what every page under that ancestor shows. Somebody who runs `/guides` may
* therefore set `/guides/foo` to override or hide, but may not reach up and rewrite the menu
* `/guides` hands down unless their rules reach `/guides` too.
*
* Which entry that is depends on the mode the request is SETTING, not on the one stored: a page
* being pointed at `override` is about to own its menu, so the question is about its own path. Only
* `inherit` reaches upwards. `menuOwnerRef` is what resolves the two cases; null from it means the
* sidebar above is hidden, so there is no menu and nothing to edit.
*/
async function mayEditNavItems(
req: FastifyRequest,
siteId: string,
pageId: string,
mode: NavigationMode
): Promise<boolean> {
if (!(await mayManageNavAt(req, siteId, pageId))) {
return false
}
const owner = await WIKI.models.navigation.menuOwnerRef(siteId, pageId, mode)
if (!owner) {
return false
}
return mayOnPage(req, 'manage:navigation', { ...owner, siteId })
}
const navigationItem = { const navigationItem = {
type: 'object', type: 'object',
properties: { properties: {
@ -24,17 +76,6 @@ const navigationItem = {
} }
} }
/** Whether the requester may see and edit a menu whole, rather than only the parts meant for them. */
function canManageNavigation(req: FastifyRequest): boolean {
// -> Same identity resolution as the route permission hook, so a key that may save a menu may also
// read it whole
const permissions = req.apiKey
? req.apiKey.permissions
: req.session?.authenticated
? (req.session.permissions ?? [])
: []
return permissions.includes('manage:navigation') || permissions.includes('manage:system')
}
/** /**
* Navigation API Routes * Navigation API Routes
@ -89,8 +130,18 @@ async function routes(app: FastifyInstance) {
}, },
async (req, reply) => { async (req, reply) => {
const unfiltered = Boolean(req.query.full) const unfiltered = Boolean(req.query.full)
if (unfiltered && !canManageNavigation(req)) { /*
return reply.forbidden('Reading a menu in full requires the manage:navigation permission.') Reading a menu WHOLE -- including the items limited to groups the requester is not in -- is
the editor's request rather than a reader's, so it asks the same question editing the items
does: `manage:navigation` on the entry the menu belongs to.
*/
if (unfiltered) {
const owner = await WIKI.models.navigation.refForNavId(req.params.siteId, req.params.navId)
if (!owner || !mayOnPage(req, 'manage:navigation', { ...owner, siteId: req.params.siteId })) {
return reply.forbidden(
'Reading a menu in full requires the manage:navigation permission on the page it belongs to.'
)
}
} }
return WIKI.models.navigation.getNav(req.params.navId, { return WIKI.models.navigation.getNav(req.params.navId, {
userGroups: req.session?.authenticated ? (req.session.groups ?? []) : [], userGroups: req.session?.authenticated ? (req.session.groups ?? []) : [],
@ -105,9 +156,10 @@ async function routes(app: FastifyInstance) {
app.get<{ Params: { siteId: string; pageId: string } }>( app.get<{ Params: { siteId: string; pageId: string } }>(
'/sites/:siteId/navigation/pages/:pageId/inherited', '/sites/:siteId/navigation/pages/:pageId/inherited',
{ {
config: { /*
permissions: ['manage:navigation'] No route-level `permissions`: `manage:navigation` is a page rule now, and that hook reads the
}, group-wide list only. Checked against this page below instead.
*/
schema: { schema: {
summary: 'Get the menu a page inherits', summary: 'Get the menu a page inherits',
description: description:
@ -130,18 +182,33 @@ async function routes(app: FastifyInstance) {
type: ['string', 'null'], type: ['string', 'null'],
description: description:
'The menu this page inherits. Null when the sidebar above it is hidden.' 'The menu this page inherits. Null when the sidebar above it is hidden.'
},
canEditItems: {
type: 'boolean',
description:
'Whether this requester may edit the items of that menu, as opposed to only setting this page\'s navigation mode. False when `manage:navigation` does not also reach the entry the menu belongs to — the items are shown to every page under that entry, so changing them is a change there rather than here.'
} }
} }
} }
} }
} }
}, },
async (req) => { async (req, reply) => {
if (!(await mayManageNavAt(req, req.params.siteId, req.params.pageId))) {
return reply.forbidden('You are not allowed to manage the navigation of this page.')
}
/*
`canEditItems` comes back with the id because the editor has to know which of its two halves
to offer: whoever may set the mode here but not reach the ancestor that owns the menu gets the
mode controls and a read-only list. Answered by the server rather than worked out in the
browser, since only the server can evaluate a rule against the ancestor's path.
*/
return { return {
navigationId: await WIKI.models.navigation.inheritedNavId( navigationId: await WIKI.models.navigation.inheritedNavId(
req.params.siteId, req.params.siteId,
req.params.pageId req.params.pageId
) ),
canEditItems: await mayEditNavItems(req, req.params.siteId, req.params.pageId, 'inherit')
} }
} }
) )
@ -155,9 +222,10 @@ async function routes(app: FastifyInstance) {
}>( }>(
'/sites/:siteId/navigation/pages/:pageId', '/sites/:siteId/navigation/pages/:pageId',
{ {
config: { /*
permissions: ['manage:navigation'] No route-level `permissions`: see the note on the route above. The two halves of this request
}, are checked separately below, because they are two different permissions to hold.
*/
schema: { schema: {
summary: 'Set how a page resolves its navigation', summary: 'Set how a page resolves its navigation',
description: description:
@ -208,7 +276,24 @@ async function routes(app: FastifyInstance) {
} }
} }
}, },
async (req) => { async (req, reply) => {
if (!(await mayManageNavAt(req, req.params.siteId, req.params.pageId))) {
return reply.forbidden('You are not allowed to manage the navigation of this page.')
}
/*
Sending `items` is editing the menu where it LIVES, which for a page that inherits is an
ancestor's. Refused separately from the mode so that the common case -- a section editor
pointing one of their own pages at a different mode -- is not held up by a permission they do
not need for it.
*/
if (
req.body.items !== undefined &&
!(await mayEditNavItems(req, req.params.siteId, req.params.pageId, req.body.mode))
) {
return reply.forbidden(
'You are not allowed to edit the items of the menu this page shows, as it belongs to a page you do not manage the navigation of. You can still change how this page resolves its navigation.'
)
}
const result = await WIKI.models.navigation.updateNavigation({ const result = await WIKI.models.navigation.updateNavigation({
siteId: req.params.siteId, siteId: req.params.siteId,
pageId: req.params.pageId, pageId: req.params.pageId,

@ -110,7 +110,8 @@ const PAGE_PERMISSIONS = [
'manage:assets', 'manage:assets',
'read:comments', 'read:comments',
'write:comments', 'write:comments',
'manage:comments' 'manage:comments',
'manage:navigation'
] ]
export function mayBypassPassword(req: FastifyRequest): boolean { export function mayBypassPassword(req: FastifyRequest): boolean {

@ -92,6 +92,11 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
type: 'number', type: 'number',
description: 'Number of users assigned to this group.' description: 'Number of users assigned to this group.'
}, },
isElevated: {
type: 'boolean',
description:
'Whether this group carries a permission that administers the wiki (`write:users`, `manage:users`, `write:groups`, `manage:groups`, `manage:system`). Membership of such a group is itself a privilege, so only `manage:system` may move a user in or out of one. A flag rather than the permissions themselves, so that a caller who may not read a group can still be told which ones are out of bounds.'
},
createdAt: { createdAt: {
type: 'string', type: 'string',
format: 'date-time', format: 'date-time',

@ -309,7 +309,7 @@ async function routes(app: FastifyInstance) {
schema: { schema: {
summary: 'Update a site', summary: 'Update a site',
description: description:
'Every site setting except its theme, which has a route of its own because `manage:theme` grants it without granting the rest of this — see `PUT /sites/{siteId}/theme`.', "Every site setting except its theme and its storage, which have routes and permissions of their own — see `PUT /sites/{siteId}/theme` (`manage:theme`) and `PUT /sites/{siteId}/storage` (`manage:storage`). The three do not overlap, so changing all of a site's settings takes all three.",
tags: ['Sites'], tags: ['Sites'],
params: { params: {
type: 'object', type: 'object',
@ -545,6 +545,10 @@ async function routes(app: FastifyInstance) {
* before this existed, since the admin area offers them the screen — or granting them every * before this existed, since the admin area offers them the screen — or granting them every
* other setting in the same body. * other setting in the same body.
* *
* `manage:sites` is NOT accepted here, and that is the point rather than an oversight: a site's
* settings are split across three permissions that do not overlap (`manage:sites`, `manage:theme`,
* `manage:storage`), so somebody who is to change all of them holds all three.
*
* There is no matching `GET`: a site's theme is public, served with the site itself to every * There is no matching `GET`: a site's theme is public, served with the site itself to every
* reader that has to draw it, so `GET /sites/{siteIdorHostname}` already answers with it and a * reader that has to draw it, so `GET /sites/{siteIdorHostname}` already answers with it and a
* second copy behind a permission would say the same thing less usefully. * second copy behind a permission would say the same thing less usefully.
@ -553,9 +557,9 @@ async function routes(app: FastifyInstance) {
'/:siteId/theme', '/:siteId/theme',
{ {
config: { config: {
// -> `manage:sites` too: whoever administers the site holds everything in it, and this route // -> `manage:theme` alone. The three permissions covering a site's settings are deliberately
// is the only way the theme is written now that the general update has given it up // disjoint, so holding `manage:sites` says nothing about the look of the site
permissions: ['manage:sites', 'manage:theme'] permissions: ['manage:theme']
}, },
schema: { schema: {
summary: "Update a site's theme", summary: "Update a site's theme",

@ -76,7 +76,7 @@ async function routes(app: FastifyInstance) {
Credentials are not what makes it `manage:system` either, because they never come back: Credentials are not what makes it `manage:system` either, because they never come back:
every `sensitive` prop is masked on the way out, here and in `PUT` below. every `sensitive` prop is masked on the way out, here and in `PUT` below.
*/ */
permissions: ['manage:sites'] permissions: ['manage:storage']
}, },
schema: { schema: {
summary: 'Get the storage configuration of a site', summary: 'Get the storage configuration of a site',
@ -170,7 +170,7 @@ async function routes(app: FastifyInstance) {
'/sites/:siteId/storage/status', '/sites/:siteId/storage/status',
{ {
config: { config: {
permissions: ['manage:sites'] permissions: ['manage:storage']
}, },
schema: { schema: {
summary: "Get the health of a site's storage targets", summary: "Get the health of a site's storage targets",
@ -237,7 +237,7 @@ async function routes(app: FastifyInstance) {
{ {
config: { config: {
// -> The same site-bound setting the `GET` above answers with; see the note there // -> The same site-bound setting the `GET` above answers with; see the note there
permissions: ['manage:sites'] permissions: ['manage:storage']
}, },
schema: { schema: {
summary: 'Update the storage configuration of a site', summary: 'Update the storage configuration of a site',
@ -316,8 +316,8 @@ async function routes(app: FastifyInstance) {
} }
/* /*
Whether this caller may point a path prop anywhere on this server. `manage:sites` is enough to Whether this caller may point a path prop anywhere on this server. `manage:storage` is enough
configure a site's storage, but a path is a place on the operator's machine rather than a to configure a site's storage, but a path is a place on the operator's machine rather than a
setting of the site — see `storage.checkLocalPath`, which is where the rule is. setting of the site — see `storage.checkLocalPath`, which is where the rule is.
*/ */
const unconfined = WIKI.models.groups.holdsSystemPermission(req) const unconfined = WIKI.models.groups.holdsSystemPermission(req)
@ -384,7 +384,7 @@ async function routes(app: FastifyInstance) {
config: { config: {
// -> An action moves this site's content between this site's targets, so it is the same // -> An action moves this site's content between this site's targets, so it is the same
// authority as configuring them // authority as configuring them
permissions: ['manage:sites'] permissions: ['manage:storage']
}, },
schema: { schema: {
summary: 'Run an action on a storage target', summary: 'Run an action on a storage target',

@ -1399,7 +1399,13 @@ async function routes(app: FastifyInstance) {
'/', '/',
{ {
config: { config: {
permissions: ['create:users', 'manage:users'] /*
`write:users` is the rung that may bring an account into existence without being trusted
with the ones that already exist: every route that CHANGES a user keeps asking for
`manage:users`. (`create:users` stood here and matched nobody -- it was not a name the
group editor offered, and nothing validates one that is not.)
*/
permissions: ['write:users', 'manage:users']
}, },
schema: { schema: {
summary: 'Create a new user', summary: 'Create a new user',
@ -1493,6 +1499,25 @@ async function routes(app: FastifyInstance) {
) )
} }
/*
Which groups a new account may be born into. Creating a user and putting them in a group that
administers the wiki is the same act as promoting an existing one, so it meets the same
refusal -- otherwise the way around every guard below would be to make a second account
instead of editing the first. Asked of `write:users` and `manage:users` alike: neither is
trusted to decide who administers the instance, which is `manage:system`'s to give.
*/
const requestedGroups = req.body.groups ?? []
if (requestedGroups.length > 0 && !WIKI.models.groups.holdsSystemPermission(req)) {
const elevated = await WIKI.models.groups.elevatedGroupIds()
if (requestedGroups.some((id) => elevated.includes(id))) {
throw new CustomError(
'groupMembershipElevatedProtected',
'Only a user who holds manage:system can create a user inside a group that administers the wiki.',
403
)
}
}
try { try {
const id = await WIKI.models.users.createUser({ const id = await WIKI.models.users.createUser({
name: req.body.name, name: req.body.name,
@ -1698,18 +1723,29 @@ async function routes(app: FastifyInstance) {
} }
/* /*
Handing somebody `manage:system` by putting them in a group that carries it. Only ADDING is Moving somebody in or out of a group that administers the wiki, which `manage:users` may
checked: a user already in such a group is protected by `systemUserGuard` above, which has not do in either direction: adding hands them whatever that group can reach, and removing
refused this request before it gets here. takes it from a real administrator. Both are checked -- an earlier version looked only at
additions, on the grounds that `systemUserGuard` above had already refused anyone already
inside such a group, which is true of `manage:system` and not of the rest of
`ELEVATED_PERMISSIONS`: a user in a `manage:groups` group is not system-protected, so
nothing else would have stopped them being quietly taken out of it.
Groups this request leaves alone are not consulted, so a save that only renames the user
still goes through whatever they belong to.
*/ */
if (!WIKI.models.groups.holdsSystemPermission(req)) { if (!WIKI.models.groups.holdsSystemPermission(req)) {
const current = await WIKI.models.users.getUserGroupIds(req.params.userId) const current = await WIKI.models.users.getUserGroupIds(req.params.userId)
const systemGroupIds = await WIKI.models.groups.systemGroupIds() const requested = req.body.groups
const added = req.body.groups.filter((id) => !current.includes(id)) const elevated = await WIKI.models.groups.elevatedGroupIds()
if (added.some((id) => systemGroupIds.includes(id))) { const moved = [
...requested.filter((id) => !current.includes(id)),
...current.filter((id) => !requested.includes(id))
]
if (moved.some((id) => elevated.includes(id))) {
throw new CustomError( throw new CustomError(
'groupMembershipSystemProtected', 'groupMembershipElevatedProtected',
'Only a user who holds the manage:system permission can add a user to a group that has it.', 'Only a user who holds manage:system can add a user to, or remove one from, a group that administers the wiki.',
403 403
) )
} }

@ -601,6 +601,10 @@
"admin.groups.nameMissing": "A group name is required.", "admin.groups.nameMissing": "A group name is required.",
"admin.groups.overview": "Overview", "admin.groups.overview": "Overview",
"admin.groups.permissions": "Permissions", "admin.groups.permissions": "Permissions",
"admin.groups.permissionsGeneral": "General Permissions",
"admin.groups.permissionsSite": "Site Management",
"admin.groups.permissionsUsers": "Users Management",
"admin.groups.permissionsWebhooks": "Webhooks Management",
"admin.groups.redirectOnFirstLogin": "First-time Login Redirect", "admin.groups.redirectOnFirstLogin": "First-time Login Redirect",
"admin.groups.redirectOnFirstLoginHint": "Optionally redirect the user to a specific page when he/she login for the first time. Leave empty to use the site-defined value.", "admin.groups.redirectOnFirstLoginHint": "Optionally redirect the user to a specific page when he/she login for the first time. Leave empty to use the site-defined value.",
"admin.groups.redirectOnLogin": "Redirect on Login", "admin.groups.redirectOnLogin": "Redirect on Login",

@ -10,6 +10,35 @@ import type { FastifyRequest } from 'fastify'
/** The permission that bypasses every check, and the one the guards below exist to protect. */ /** The permission that bypasses every check, and the one the guards below exist to protect. */
export const SYSTEM_PERMISSION = 'manage:system' export const SYSTEM_PERMISSION = 'manage:system'
/**
* The permissions that amount to running the instance rather than to running part of it.
*
* Each one is a route to every other permission on the wiki, so a group holding any of them is a
* group whose membership is itself a privilege: somebody who may create users and put them in such a
* group, or add themselves to one, has granted themselves whatever that group can reach. That is why
* the guards below are written against this list and not against `manage:system` alone — stopping
* short at the root permission would leave `manage:users` handing out `manage:groups`, and
* `manage:groups` handing back `manage:users`, with neither step looking like an escalation on its
* own.
*
* `manage:system` is the one that also bypasses every route check; the other four get here by being
* able to rewrite who holds what.
*/
export const ELEVATED_PERMISSIONS = [
'write:users',
'manage:users',
'write:groups',
'manage:groups',
SYSTEM_PERMISSION
] as const
/** Whether a permission list carries any of `ELEVATED_PERMISSIONS`. */
export function isElevated(permissions: readonly string[]): boolean {
return permissions.some((permission) =>
(ELEVATED_PERMISSIONS as readonly string[]).includes(permission)
)
}
/** How a rule addresses pages: `TAG` and `TAGALL` read `tags`, everything else reads `path`. */ /** How a rule addresses pages: `TAG` and `TAGALL` read `tags`, everything else reads `path`. */
export type GroupRuleMatch = 'START' | 'END' | 'SUBTREE' | 'REGEX' | 'TAG' | 'TAGALL' | 'EXACT' export type GroupRuleMatch = 'START' | 'END' | 'SUBTREE' | 'REGEX' | 'TAG' | 'TAGALL' | 'EXACT'
@ -239,6 +268,25 @@ class Groups {
const rule = resolvePageRule(this.rulesForGroups(actor.groupIds), permission, page) const rule = resolvePageRule(this.rulesForGroups(actor.groupIds), permission, page)
return rule ? rule.mode !== 'DENY' : false return rule ? rule.mode !== 'DENY' : false
} }
/**
* Whether any rule this actor holds grants a page permission ANYWHERE.
*
* Deliberately not a substitute for `checkAccess`, which is the question every endpoint acting on a
* page has to ask. This answers the narrower "is this person a `manage:navigation` holder at all",
* which is what a route serving the OPTIONS such an editor needs -- the group names its visibility
* field offers -- has to know, since that request names no page of its own.
*
* A `DENY` rule is not a grant, so a set of rules that only ever denies answers false.
*/
grantsAnywhere(actor: AccessActor, permission: string): boolean {
if (actor.permissions.includes('manage:system')) {
return true
}
return this.rulesForGroups(actor.groupIds).some(
(rule) => rule.mode !== 'DENY' && (rule.roles ?? []).includes(permission)
)
}
async init(ids: SystemIds): Promise<void> { async init(ids: SystemIds): Promise<void> {
WIKI.logger.info('Inserting default groups...') WIKI.logger.info('Inserting default groups...')
@ -662,6 +710,21 @@ class Groups {
.map((row) => row.id) .map((row) => row.id)
} }
/**
* The ids of every group carrying any of `ELEVATED_PERMISSIONS`.
*
* What the membership guards ask about: putting somebody into one of these, or taking them out,
* changes who administers the instance rather than what one account may read.
*/
async elevatedGroupIds(): Promise<string[]> {
const rows = await WIKI.db
.select({ id: groupsTable.id, permissions: groupsTable.permissions })
.from(groupsTable)
return rows
.filter((row) => isElevated((row.permissions ?? []) as string[]))
.map((row) => row.id)
}
/** /**
* Whether a user is protected by `manage:system` — i.e. belongs to any group carrying it. * Whether a user is protected by `manage:system` — i.e. belongs to any group carrying it.
* *

@ -1,6 +1,6 @@
import { and, eq, inArray, sql } from 'drizzle-orm' import { and, eq, inArray, sql } from 'drizzle-orm'
import { navigation as navigationTable, tree as treeTable } from '../db/schema.ts' import { navigation as navigationTable, tree as treeTable } from '../db/schema.ts'
import { CustomError } from '../helpers/common.ts' import { CustomError, decodeTreePath } from '../helpers/common.ts'
export const NAVIGATION_MODES = [ export const NAVIGATION_MODES = [
'inherit', 'inherit',
@ -192,6 +192,100 @@ class Navigation {
return this.ancestorNavId(siteId, entry.locale, entry.folderPath ?? '') return this.ancestorNavId(siteId, entry.locale, entry.folderPath ?? '')
} }
/**
* WHERE the menu a page shows actually lives, as a path the page rules can be asked about.
*
* `manage:navigation` is a page rule, so every question about it is a question about a path — and
* the path that decides whether somebody may edit MENU ITEMS is not the page they are standing on
* but the entry whose menu those items belong to. A page under `/guides` that inherits is editing
* `/guides`'s menu, and changing it changes what every page under `/guides` shows.
*
* Three shapes come back:
*
* - **An ancestor entry** — the nearest one that overrides. Its own path is what gets checked.
* - **The site root** — when nothing above overrides, the menu is the site-wide one for the locale,
* which in this model is the home page's own (`isSiteRoot` in `updateNavigation`). So the path
* is that page's, and editing the sidebar the whole wiki inherits needs a rule reaching it.
* - **Null** — the sidebar above is hidden, so there is no menu and nothing to edit.
*
* @param ownPath Where the page itself sits, used when the page overrides and so owns its menu
*/
async menuOwnerRef(
siteId: string,
pageId: string,
mode: NavigationMode
): Promise<{ navigationId: string | null; path: string; locale: string; tags: string[] } | null> {
const entry = await this.getEntry(siteId, pageId)
const folderPath = entry.folderPath ?? ''
/*
Which menu the items belong to is the MODE's answer, exactly as it is in `updateNavigation`
(`targetNavId`): a page that overrides owns the menu it is about to write, so the path to ask
about is its own. Only `inherit` reaches upwards. Getting this wrong in either direction is a
real bug -- always asking about the ancestor refuses somebody editing their own page's menu,
and always asking about the page lets them rewrite one handed down from above.
*/
if (mode !== 'inherit') {
const own = decodeTreePath(folderPath) ?? ''
return {
navigationId: entry.id,
path: own ? `${own}/${entry.fileName}` : entry.fileName,
locale: entry.locale,
tags: (entry.tags ?? []) as string[]
}
}
const navId = await this.ancestorNavId(siteId, entry.locale, folderPath)
if (!navId) {
return null
}
return this.refForNavId(siteId, navId, entry.locale)
}
/**
* The entry a menu belongs to, as a path the page rules can be asked about.
*
* A menu belonging to the tree is keyed by its entry's id; the site-wide one is keyed by site and
* locale and matches no entry, which is exactly how the two are told apart here.
*
* @param fallbackLocale The locale to report for the site-wide menu, whose owner is the home page
*/
async refForNavId(
siteId: string,
navId: string,
fallbackLocale?: string
): Promise<{ navigationId: string; path: string; locale: string; tags: string[] } | null> {
const owners = await WIKI.db
.select({
folderPath: treeTable.folderPath,
fileName: treeTable.fileName,
locale: treeTable.locale,
tags: treeTable.tags
})
.from(treeTable)
.where(and(eq(treeTable.id, navId), eq(treeTable.siteId, siteId)))
.limit(1)
const owner = owners[0]
if (owner) {
const ownerFolder = owner.folderPath ? decodeTreePath(owner.folderPath) : ''
return {
navigationId: navId,
path: ownerFolder ? `${ownerFolder}/${owner.fileName}` : owner.fileName,
locale: owner.locale,
tags: (owner.tags ?? []) as string[]
}
}
// -> The site-wide menu, which the home page owns. `home` is the path `isSiteRoot` recognises.
const siteMenus = await WIKI.db
.select({ locale: navigationTable.locale })
.from(navigationTable)
.where(and(eq(navigationTable.id, navId), eq(navigationTable.siteId, siteId)))
.limit(1)
const locale = siteMenus[0]?.locale ?? fallbackLocale
if (!locale) {
return null
}
return { navigationId: navId, path: 'home', locale, tags: [] }
}
/** /**
* Set how a page decides its sidebar, and optionally the menu itself. * Set how a page decides its sidebar, and optionally the menu itself.
* *

@ -804,7 +804,7 @@ class Storage {
/** /**
* Whether this caller may set a path prop to this value. * Whether this caller may set a path prop to this value.
* *
* Only reached for a caller who does NOT hold `manage:system`, i.e. a site administrator. Three * Only reached for a caller who does NOT hold `manage:system` — i.e. a `manage:storage` holder. Three
* things follow from the fact that a path on this server is the operator's territory rather than a * things follow from the fact that a path on this server is the operator's territory rather than a
* site's, and only the first is about where the path points: * site's, and only the first is about where the path points:
* *

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts. removing an icon; `check-icons.mjs` fails the build if this drifts.
273 icons. 271 icons.
*/ */
export const BUNDLED_ICONS = { export const BUNDLED_ICONS = {
"la:angle-right": {"body":"<path fill=\"currentColor\" d=\"M12.969 4.281L11.53 5.72L21.812 16l-10.28 10.281l1.437 1.438l11-11l.687-.719l-.687-.719z\"/>","width":32,"height":32}, "la:angle-right": {"body":"<path fill=\"currentColor\" d=\"M12.969 4.281L11.53 5.72L21.812 16l-10.28 10.281l1.437 1.438l11-11l.687-.719l-.687-.719z\"/>","width":32,"height":32},
@ -68,6 +68,7 @@ export const BUNDLED_ICONS = {
"la:file-upload": {"body":"<path fill=\"currentColor\" d=\"M6 3v26h20V9.6l-.3-.3l-6-6l-.3-.3zm2 2h10v6h6v16H8zm12 1.4L22.6 9H20zM16 13l-4 4h3v5h2v-5h3zm-4 10v2h8v-2z\"/>","width":32,"height":32}, "la:file-upload": {"body":"<path fill=\"currentColor\" d=\"M6 3v26h20V9.6l-.3-.3l-6-6l-.3-.3zm2 2h10v6h6v16H8zm12 1.4L22.6 9H20zM16 13l-4 4h3v5h2v-5h3zm-4 10v2h8v-2z\"/>","width":32,"height":32},
"la:fill": {"body":"<path fill=\"currentColor\" d=\"M11.313 3.281L9.905 4.72l1.782 1.78l-6.906 6.906a3.063 3.063 0 0 0 0 4.313l.063.062l6.343 6.313a3.063 3.063 0 0 0 4.313 0l7.594-7.594l.718-.688l-9.718-9.718l-.781-.813l-.22-.187zm1.812 4.656L21 15.813l-6.906 6.876a1.054 1.054 0 0 1-1.5 0L6.219 16.28a1.017 1.017 0 0 1 0-1.468zM25 19.25l-.813 1.188s-.539.753-1.062 1.656c-.262.453-.508.926-.719 1.406S22 24.422 22 25c0 1.645 1.355 3 3 3s3-1.355 3-3c0-.578-.195-1.02-.406-1.5s-.457-.953-.719-1.406c-.523-.903-1.063-1.657-1.063-1.657zm0 3.625c.066.11.059.102.125.219c.238.41.492.847.656 1.218c.164.372.219.715.219.688c0 .555-.445 1-1 1s-1-.445-1-1c0 .027.055-.316.219-.688c.164-.37.418-.808.656-1.218c.066-.117.059-.11.125-.219\"/>","width":32,"height":32}, "la:fill": {"body":"<path fill=\"currentColor\" d=\"M11.313 3.281L9.905 4.72l1.782 1.78l-6.906 6.906a3.063 3.063 0 0 0 0 4.313l.063.062l6.343 6.313a3.063 3.063 0 0 0 4.313 0l7.594-7.594l.718-.688l-9.718-9.718l-.781-.813l-.22-.187zm1.812 4.656L21 15.813l-6.906 6.876a1.054 1.054 0 0 1-1.5 0L6.219 16.28a1.017 1.017 0 0 1 0-1.468zM25 19.25l-.813 1.188s-.539.753-1.062 1.656c-.262.453-.508.926-.719 1.406S22 24.422 22 25c0 1.645 1.355 3 3 3s3-1.355 3-3c0-.578-.195-1.02-.406-1.5s-.457-.953-.719-1.406c-.523-.903-1.063-1.657-1.063-1.657zm0 3.625c.066.11.059.102.125.219c.238.41.492.847.656 1.218c.164.372.219.715.219.688c0 .555-.445 1-1 1s-1-.445-1-1c0 .027.055-.316.219-.688c.164-.37.418-.808.656-1.218c.066-.117.059-.11.125-.219\"/>","width":32,"height":32},
"la:fingerprint": {"body":"<path fill=\"currentColor\" d=\"M16 4c-.262 0-.496.016-.75.031a13 13 0 0 0-4.063.875l.75 1.875a10.8 10.8 0 0 1 3.407-.75C15.55 6.02 15.774 6 16 6c1.883 0 3.664.477 5.219 1.313l.937-1.75A13 13 0 0 0 16 4M9.5 5.719a13 13 0 0 0-3.188 2.593c-.414.461-.777.981-1.125 1.5c-.382.57-.714 1.168-1 1.782L6 12.406a11.2 11.2 0 0 1 1.813-2.75A11 11 0 0 1 10.5 7.47zm14.469 1L22.75 8.312a10.93 10.93 0 0 1 4.219 8.094c.004.063.047.61 0 1.532l2 .125c.05-1.004.008-1.665 0-1.782a12.94 12.94 0 0 0-5-9.562M16 7v2c4.25 0 7.77 3.313 8 7.563c.008.113.129 3.066-1 6.625l1.906.593c1.239-3.902 1.11-7.031 1.094-7.312C25.715 11.176 21.293 7 16 7m-1.844.156a9.9 9.9 0 0 0-5.594 3.157c-.32.355-.636.753-.906 1.156h.032v.031C6.52 13.262 5.902 15.3 6 17.406v.563l2 .062v-.656c-.09-1.715.383-3.375 1.344-4.813c.21-.32.433-.624.687-.906a7.96 7.96 0 0 1 4.5-2.531zM15.594 10a6.9 6.9 0 0 0-4.25 1.781l1.312 1.5A5 5 0 0 1 15.72 12c.105-.008.183 0 .281 0c.582 0 1.14.098 1.656.281l.688-1.875A7.1 7.1 0 0 0 16 10c-.145 0-.27-.008-.406 0m4.281 1.156l-1.094 1.688A4.95 4.95 0 0 1 21 16.719l2-.094a7.05 7.05 0 0 0-3.125-5.469M15.781 13a4 4 0 0 0-2.75 1.344A3.98 3.98 0 0 0 12 17.219c0-.004.05 1.125-.406 2.437c-.457 1.313-1.371 2.793-3.344 3.75l-.625.282c-.332.148-.75.32-.844.343l.438 1.938c.445-.102.875-.301 1.25-.469s.656-.313.656-.313c2.5-1.21 3.762-3.207 4.344-4.875c.582-1.667.539-2.996.531-3.187v-.031a1.93 1.93 0 0 1 .5-1.438A1.95 1.95 0 0 1 15.875 15c.05-.004.09 0 .125 0v-2c-.082 0-.148-.004-.219 0m-5.625.125A6.96 6.96 0 0 0 9 17.344v.031c.004.082.09 2.266-2.063 3.313C6.891 20.706 6.146 21 5 21v2c1.566 0 2.75-.469 2.75-.469h.031l.032-.031c3.222-1.563 3.19-5.04 3.187-5.219v-.031c-.059-1.09.25-2.11.844-3zm7.75.344l-.968 1.781c.593.32 1.023.902 1.062 1.625c.008.164.285 6.387-4.625 10.344l1.25 1.562c5.719-4.605 5.402-11.531 5.375-12a4 4 0 0 0-2.094-3.312M16 16c-.55 0-1 .45-1 1v.063s.117 2.058-.906 4.375l1.812.812C17.09 19.574 17.008 17.172 17 17v-.063A1.004 1.004 0 0 0 16 16m4.969 1.938c-.125 2.03-.766 6.195-3.719 9.687l1.5 1.281c3.363-3.972 4.078-8.558 4.219-10.843zM13.562 22.5c-.8 1.348-2.039 2.645-4 3.594l.876 1.812c2.32-1.125 3.87-2.77 4.843-4.406z\"/>","width":32,"height":32}, "la:fingerprint": {"body":"<path fill=\"currentColor\" d=\"M16 4c-.262 0-.496.016-.75.031a13 13 0 0 0-4.063.875l.75 1.875a10.8 10.8 0 0 1 3.407-.75C15.55 6.02 15.774 6 16 6c1.883 0 3.664.477 5.219 1.313l.937-1.75A13 13 0 0 0 16 4M9.5 5.719a13 13 0 0 0-3.188 2.593c-.414.461-.777.981-1.125 1.5c-.382.57-.714 1.168-1 1.782L6 12.406a11.2 11.2 0 0 1 1.813-2.75A11 11 0 0 1 10.5 7.47zm14.469 1L22.75 8.312a10.93 10.93 0 0 1 4.219 8.094c.004.063.047.61 0 1.532l2 .125c.05-1.004.008-1.665 0-1.782a12.94 12.94 0 0 0-5-9.562M16 7v2c4.25 0 7.77 3.313 8 7.563c.008.113.129 3.066-1 6.625l1.906.593c1.239-3.902 1.11-7.031 1.094-7.312C25.715 11.176 21.293 7 16 7m-1.844.156a9.9 9.9 0 0 0-5.594 3.157c-.32.355-.636.753-.906 1.156h.032v.031C6.52 13.262 5.902 15.3 6 17.406v.563l2 .062v-.656c-.09-1.715.383-3.375 1.344-4.813c.21-.32.433-.624.687-.906a7.96 7.96 0 0 1 4.5-2.531zM15.594 10a6.9 6.9 0 0 0-4.25 1.781l1.312 1.5A5 5 0 0 1 15.72 12c.105-.008.183 0 .281 0c.582 0 1.14.098 1.656.281l.688-1.875A7.1 7.1 0 0 0 16 10c-.145 0-.27-.008-.406 0m4.281 1.156l-1.094 1.688A4.95 4.95 0 0 1 21 16.719l2-.094a7.05 7.05 0 0 0-3.125-5.469M15.781 13a4 4 0 0 0-2.75 1.344A3.98 3.98 0 0 0 12 17.219c0-.004.05 1.125-.406 2.437c-.457 1.313-1.371 2.793-3.344 3.75l-.625.282c-.332.148-.75.32-.844.343l.438 1.938c.445-.102.875-.301 1.25-.469s.656-.313.656-.313c2.5-1.21 3.762-3.207 4.344-4.875c.582-1.667.539-2.996.531-3.187v-.031a1.93 1.93 0 0 1 .5-1.438A1.95 1.95 0 0 1 15.875 15c.05-.004.09 0 .125 0v-2c-.082 0-.148-.004-.219 0m-5.625.125A6.96 6.96 0 0 0 9 17.344v.031c.004.082.09 2.266-2.063 3.313C6.891 20.706 6.146 21 5 21v2c1.566 0 2.75-.469 2.75-.469h.031l.032-.031c3.222-1.563 3.19-5.04 3.187-5.219v-.031c-.059-1.09.25-2.11.844-3zm7.75.344l-.968 1.781c.593.32 1.023.902 1.062 1.625c.008.164.285 6.387-4.625 10.344l1.25 1.562c5.719-4.605 5.402-11.531 5.375-12a4 4 0 0 0-2.094-3.312M16 16c-.55 0-1 .45-1 1v.063s.117 2.058-.906 4.375l1.812.812C17.09 19.574 17.008 17.172 17 17v-.063A1.004 1.004 0 0 0 16 16m4.969 1.938c-.125 2.03-.766 6.195-3.719 9.687l1.5 1.281c3.363-3.972 4.078-8.558 4.219-10.843zM13.562 22.5c-.8 1.348-2.039 2.645-4 3.594l.876 1.812c2.32-1.125 3.87-2.77 4.843-4.406z\"/>","width":32,"height":32},
"la:fire-alt": {"body":"<path fill=\"currentColor\" d=\"m16.799 4.39l-2.996 4.997l-1.85-1.848l-.703.799C7.767 12.286 6 15.873 6 19c0 4.962 4.486 9 10 9s10-4.038 10-9c0-4.762-5.197-10.634-8.295-13.71zm.392 3.233C19.767 10.309 24 15.288 24 19c0 2.391-1.38 4.504-3.477 5.768A6 6 0 0 0 21 22.43c0-2.381-1.685-5.206-3.098-7.155l-.843-1.166l-2.215 3.323l-1.406-1.407l-.66 1.09C11.597 19.061 11 20.85 11 22.43c0 .837.178 1.624.477 2.338C9.38 23.504 8 21.39 8 19s1.398-5.323 4.057-8.53l2.14 2.143zm-.087 10.025C18.334 19.565 19 21.234 19 22.43c0 1.969-1.346 3.57-3 3.57s-3-1.601-3-3.57c0-.922.29-1.978.865-3.149l1.291 1.29z\"/>","width":32,"height":32},
"la:folder-open": {"body":"<path fill=\"currentColor\" d=\"M5 3v24.813l.781.156l12 2.5l1.219.25V28h6V15.437l1.719-1.718l.281-.313V3zm9.125 2H25v7.563l-1.719 1.718l-.281.313V26h-4v-8.906l-.281-.313L17 15.063V5.719zM7 5.281l8 2v8.625l.281.313L17 17.937v10.344L7 26.188z\"/>","width":32,"height":32}, "la:folder-open": {"body":"<path fill=\"currentColor\" d=\"M5 3v24.813l.781.156l12 2.5l1.219.25V28h6V15.437l1.719-1.718l.281-.313V3zm9.125 2H25v7.563l-1.719 1.718l-.281.313V26h-4v-8.906l-.281-.313L17 15.063V5.719zM7 5.281l8 2v8.625l.281.313L17 17.937v10.344L7 26.188z\"/>","width":32,"height":32},
"la:font": {"body":"<path fill=\"currentColor\" d=\"M15 6L8 26h2l2.094-6h7.812L22 26h2L17 6zm1 2.844L19.188 18h-6.375z\"/>","width":32,"height":32}, "la:font": {"body":"<path fill=\"currentColor\" d=\"M15 6L8 26h2l2.094-6h7.812L22 26h2L17 6zm1 2.844L19.188 18h-6.375z\"/>","width":32,"height":32},
"la:frog": {"body":"<path fill=\"currentColor\" d=\"M21 7a3 3 0 0 0-2.625 1.563c-.34.199-1.023.632-1.781 1.468c-.934.016-2.676.086-4.657.719c-2.117.676-4.386 2.047-5.656 4.563c-.011.027-.05.035-.062.062c-.051.055-.535.555-1.063 1.344C4.606 17.547 4 18.633 4 19.906c0 .918.32 1.84 1 2.5c.223.219.512.371.813.5c-.118.157-.243.309-.344.469c-.23.36-.469.613-.469 1.281c0 .418.305.817.531.969s.387.21.563.25c.347.078.71.086 1.125.094c.832.011 1.914-.035 3.125-.094c2.426-.117 5.39-.223 7.5.125l.312-2c-1-.164-2.11-.23-3.218-.25c.613-.652 1.062-1.469 1.062-2.469c0-1.597-1.031-2.808-2.219-3.437C12.594 17.214 11.215 17 10 17v2c.941 0 2.07.215 2.844.625c.773.41 1.156.848 1.156 1.656c0 .653-.531 1.27-1.406 1.782c-.875.511-2.043.78-2.406.78v.032c-1.083.05-2.004.094-2.688.094c.105-.14.2-.266.313-.407c.492-.605 1-1.125 1-1.125l-1.47-1.375l-.03.063c-.504.148-.723.05-.938-.156S6 20.336 6 19.906c0-.531.395-1.386.844-2.062s.875-1.157.875-1.157l.125-.125l.062-.156c.93-2.062 2.774-3.148 4.657-3.75C14.444 12.055 16.305 12 17 12h.5l.313-.406c.136-.188.285-.32.437-.469C18.703 12.215 19.758 13 21 13c1.59 0 2.883-1.273 2.969-2.844c.457.114.91.266 1.281.469c.32.176.578.371.719.5c-.008.04-.016.02-.032.063c-.066.183-.203.398-.375.656c-.347.511-.921 1.101-1.937 1.5l-.625.25v.687c0 1.403-.125 4.852-2.063 6.219l1.157 1.625c2.808-1.984 2.843-5.563 2.843-7.25c1.043-.54 1.82-1.227 2.282-1.906c.273-.406.472-.785.593-1.125c.122-.34.188-.61.188-.938c0-.773-.383-.969-.688-1.25a5.6 5.6 0 0 0-1.125-.781c-.812-.441-1.835-.793-2.937-.844A3 3 0 0 0 21 7m0 2c.563 0 1 .438 1 1s-.438 1-1 1s-1-.438-1-1s.438-1 1-1m-2 7.375a6.3 6.3 0 0 0-1.094 2.688c-.12.777-.09 1.496-.093 1.53v.032c0 .262.046.297.062.344l.031.093c.024.063.063.122.094.188c.059.129.125.281.219.469c.191.375.46.867.75 1.375c.582 1.011 1.164 2.058 1.937 2.718q.177.16.407.22c.46.105.664-.016.875-.063s.417-.086.656-.125c.472-.074 1.054-.106 1.875.125l.562-1.938a6.4 6.4 0 0 0-2.75-.156c-.242.04-.402.09-.562.125c-.278-.328-.809-1.082-1.282-1.906a28 28 0 0 1-.875-1.657c-.003-.007.004-.027 0-.03c.004-.313-.003-.598.063-1.032c.086-.55.29-1.191.75-1.844z\"/>","width":32,"height":32}, "la:frog": {"body":"<path fill=\"currentColor\" d=\"M21 7a3 3 0 0 0-2.625 1.563c-.34.199-1.023.632-1.781 1.468c-.934.016-2.676.086-4.657.719c-2.117.676-4.386 2.047-5.656 4.563c-.011.027-.05.035-.062.062c-.051.055-.535.555-1.063 1.344C4.606 17.547 4 18.633 4 19.906c0 .918.32 1.84 1 2.5c.223.219.512.371.813.5c-.118.157-.243.309-.344.469c-.23.36-.469.613-.469 1.281c0 .418.305.817.531.969s.387.21.563.25c.347.078.71.086 1.125.094c.832.011 1.914-.035 3.125-.094c2.426-.117 5.39-.223 7.5.125l.312-2c-1-.164-2.11-.23-3.218-.25c.613-.652 1.062-1.469 1.062-2.469c0-1.597-1.031-2.808-2.219-3.437C12.594 17.214 11.215 17 10 17v2c.941 0 2.07.215 2.844.625c.773.41 1.156.848 1.156 1.656c0 .653-.531 1.27-1.406 1.782c-.875.511-2.043.78-2.406.78v.032c-1.083.05-2.004.094-2.688.094c.105-.14.2-.266.313-.407c.492-.605 1-1.125 1-1.125l-1.47-1.375l-.03.063c-.504.148-.723.05-.938-.156S6 20.336 6 19.906c0-.531.395-1.386.844-2.062s.875-1.157.875-1.157l.125-.125l.062-.156c.93-2.062 2.774-3.148 4.657-3.75C14.444 12.055 16.305 12 17 12h.5l.313-.406c.136-.188.285-.32.437-.469C18.703 12.215 19.758 13 21 13c1.59 0 2.883-1.273 2.969-2.844c.457.114.91.266 1.281.469c.32.176.578.371.719.5c-.008.04-.016.02-.032.063c-.066.183-.203.398-.375.656c-.347.511-.921 1.101-1.937 1.5l-.625.25v.687c0 1.403-.125 4.852-2.063 6.219l1.157 1.625c2.808-1.984 2.843-5.563 2.843-7.25c1.043-.54 1.82-1.227 2.282-1.906c.273-.406.472-.785.593-1.125c.122-.34.188-.61.188-.938c0-.773-.383-.969-.688-1.25a5.6 5.6 0 0 0-1.125-.781c-.812-.441-1.835-.793-2.937-.844A3 3 0 0 0 21 7m0 2c.563 0 1 .438 1 1s-.438 1-1 1s-1-.438-1-1s.438-1 1-1m-2 7.375a6.3 6.3 0 0 0-1.094 2.688c-.12.777-.09 1.496-.093 1.53v.032c0 .262.046.297.062.344l.031.093c.024.063.063.122.094.188c.059.129.125.281.219.469c.191.375.46.867.75 1.375c.582 1.011 1.164 2.058 1.937 2.718q.177.16.407.22c.46.105.664-.016.875-.063s.417-.086.656-.125c.472-.074 1.054-.106 1.875.125l.562-1.938a6.4 6.4 0 0 0-2.75-.156c-.242.04-.402.09-.562.125c-.278-.328-.809-1.082-1.282-1.906a28 28 0 0 1-.875-1.657c-.003-.007.004-.027 0-.03c.004-.313-.003-.598.063-1.032c.086-.55.29-1.191.75-1.844z\"/>","width":32,"height":32},
@ -87,6 +88,7 @@ export const BUNDLED_ICONS = {
"la:info-circle": {"body":"<path fill=\"currentColor\" d=\"M16 3C8.832 3 3 8.832 3 16s5.832 13 13 13s13-5.832 13-13S23.168 3 16 3m0 2c6.086 0 11 4.914 11 11s-4.914 11-11 11S5 22.086 5 16S9.914 5 16 5m-1 5v2h2v-2zm0 4v8h2v-8z\"/>","width":32,"height":32}, "la:info-circle": {"body":"<path fill=\"currentColor\" d=\"M16 3C8.832 3 3 8.832 3 16s5.832 13 13 13s13-5.832 13-13S23.168 3 16 3m0 2c6.086 0 11 4.914 11 11s-4.914 11-11 11S5 22.086 5 16S9.914 5 16 5m-1 5v2h2v-2zm0 4v8h2v-8z\"/>","width":32,"height":32},
"la:js-square": {"body":"<path fill=\"currentColor\" d=\"M5 5v22h22V5zm2 2h18v18H7zm13.244 8c-1.425 0-2.346.912-2.346 2.12c0 1.31.77 1.937 1.928 2.43l.4.173c.733.323 1.169.511 1.169 1.062c0 .465-.427.799-1.092.799c-.788 0-1.236-.418-1.578-.979l-1.31.75c.464.931 1.433 1.645 2.925 1.645c1.52 0 2.66-.788 2.66-2.232c0-1.35-.77-1.949-2.139-2.528l-.398-.172c-.693-.304-.988-.503-.988-.978c0-.39.294-.694.77-.694c.465 0 .758.2 1.034.694l1.256-.807c-.532-.93-1.265-1.283-2.29-1.283zm-5.85.096v5.463c0 .798-.342 1.005-.865 1.005c-.55 0-.788-.379-1.035-.826l-1.31.79c.38.807 1.129 1.472 2.412 1.472C15.02 23 16 22.24 16 20.576v-5.48z\"/>","width":32,"height":32}, "la:js-square": {"body":"<path fill=\"currentColor\" d=\"M5 5v22h22V5zm2 2h18v18H7zm13.244 8c-1.425 0-2.346.912-2.346 2.12c0 1.31.77 1.937 1.928 2.43l.4.173c.733.323 1.169.511 1.169 1.062c0 .465-.427.799-1.092.799c-.788 0-1.236-.418-1.578-.979l-1.31.75c.464.931 1.433 1.645 2.925 1.645c1.52 0 2.66-.788 2.66-2.232c0-1.35-.77-1.949-2.139-2.528l-.398-.172c-.693-.304-.988-.503-.988-.978c0-.39.294-.694.77-.694c.465 0 .758.2 1.034.694l1.256-.807c-.532-.93-1.265-1.283-2.29-1.283zm-5.85.096v5.463c0 .798-.342 1.005-.865 1.005c-.55 0-.788-.379-1.035-.826l-1.31.79c.38.807 1.129 1.472 2.412 1.472C15.02 23 16 22.24 16 20.576v-5.48z\"/>","width":32,"height":32},
"la:key": {"body":"<path fill=\"currentColor\" d=\"M20 3c-4.945 0-9 4.055-9 9c0 .52.086.977.156 1.438L3.281 21.28L3 21.594V29h7v-3h3v-3h3v-2.969c1.18.578 2.555.969 4 .969c4.945 0 9-4.055 9-9s-4.055-9-9-9m0 2c3.855 0 7 3.145 7 7s-3.145 7-7 7a7.36 7.36 0 0 1-3.406-.875l-.25-.125H14v3h-3v3H8v3H5v-4.563l7.906-7.937l.375-.344l-.094-.531C13.086 13.023 13 12.488 13 12c0-3.855 3.145-7 7-7m2 3a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4\"/>","width":32,"height":32}, "la:key": {"body":"<path fill=\"currentColor\" d=\"M20 3c-4.945 0-9 4.055-9 9c0 .52.086.977.156 1.438L3.281 21.28L3 21.594V29h7v-3h3v-3h3v-2.969c1.18.578 2.555.969 4 .969c4.945 0 9-4.055 9-9s-4.055-9-9-9m0 2c3.855 0 7 3.145 7 7s-3.145 7-7 7a7.36 7.36 0 0 1-3.406-.875l-.25-.125H14v3h-3v3H8v3H5v-4.563l7.906-7.937l.375-.344l-.094-.531C13.086 13.023 13 12.488 13 12c0-3.855 3.145-7 7-7m2 3a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4\"/>","width":32,"height":32},
"la:landmark": {"body":"<path fill=\"currentColor\" d=\"M16 3.906L3.625 9.062L3 9.345V12h2v11H3v5h26v-5h-2V12h2V9.344l-.625-.281zm0 2.188L25.375 10H6.625zM7 12h2v11H7zm4 0h2v11h-2zm4 0h2v11h-2zm4 0h2v11h-2zm4 0h2v11h-2zM5 25h22v1H5z\"/>","width":32,"height":32},
"la:language": {"body":"<path fill=\"currentColor\" d=\"M4 4v18h6v6h18V10h-6V4zm2 2h14v4.563L10.562 20H6zm5 2v1H8v2h4.938c-.13 1.148-.481 2.055-1.063 2.688a4.5 4.5 0 0 1-.906-.407C10.266 12.863 10 12.418 10 12H8c0 1.191.734 2.184 1.719 2.844A8.3 8.3 0 0 1 8 15v2c1.773 0 3.25-.406 4.375-1.156c.523.09 1.055.156 1.625.156v-1.875c.543-.91.832-1.973.938-3.125H16V9h-3V8zm10.438 4H26v14H12v-4.563zM20 13.844l-.938 2.844l-2 6l-.062.156V24h2v-.875l.031-.125h1.938l.031.125V24h2v-1.156l-.063-.157l-2-6zm0 6.281l.281.875h-.562z\"/>","width":32,"height":32}, "la:language": {"body":"<path fill=\"currentColor\" d=\"M4 4v18h6v6h18V10h-6V4zm2 2h14v4.563L10.562 20H6zm5 2v1H8v2h4.938c-.13 1.148-.481 2.055-1.063 2.688a4.5 4.5 0 0 1-.906-.407C10.266 12.863 10 12.418 10 12H8c0 1.191.734 2.184 1.719 2.844A8.3 8.3 0 0 1 8 15v2c1.773 0 3.25-.406 4.375-1.156c.523.09 1.055.156 1.625.156v-1.875c.543-.91.832-1.973.938-3.125H16V9h-3V8zm10.438 4H26v14H12v-4.563zM20 13.844l-.938 2.844l-2 6l-.062.156V24h2v-.875l.031-.125h1.938l.031.125V24h2v-1.156l-.063-.157l-2-6zm0 6.281l.281.875h-.562z\"/>","width":32,"height":32},
"la:leaf": {"body":"<path fill=\"currentColor\" d=\"M25.031 4L24 4.469c-3.18 1.402-7.773 1.93-11.688 3.312c-1.957.692-3.769 1.614-5.124 3.094C5.832 12.355 5 14.395 5 17c0 2.695 1.586 4.543 3.063 5.594a10 10 0 0 0 1.75 1c-.618 1.144-1.075 2.176-1.376 3.062c-.546 1.598-.632 2.848-.593 3.75s.156 1.52.156 1.5l2 .188c.063-.723-.125-1.102-.156-1.782s.027-1.613.5-3c.945-2.769 3.566-7.328 10.375-14.625l-1.438-1.374c-4.082 4.374-6.718 7.785-8.437 10.53a7.7 7.7 0 0 1-1.625-.874C8.059 20.145 7 18.937 7 17c0-2.203.629-3.656 1.656-4.781c1.028-1.125 2.524-1.93 4.313-2.563c3.238-1.144 7.246-1.722 10.687-3C23.976 7.871 25 12.031 25 18.531c0 3.688-.863 5.801-1.875 6.969s-2.23 1.5-3.313 1.5c-1.078 0-2.242-.55-3.25-1.25c-1.007-.7-1.703-1.43-2.406-1.875l-1.062 1.688c.18.113 1.176 1.035 2.344 1.843c1.167.809 2.648 1.594 4.374 1.594c1.54 0 3.422-.543 4.844-2.188C26.078 25.169 27 22.527 27 18.532c0-7.821-1.656-13.438-1.656-13.438z\"/>","width":32,"height":32}, "la:leaf": {"body":"<path fill=\"currentColor\" d=\"M25.031 4L24 4.469c-3.18 1.402-7.773 1.93-11.688 3.312c-1.957.692-3.769 1.614-5.124 3.094C5.832 12.355 5 14.395 5 17c0 2.695 1.586 4.543 3.063 5.594a10 10 0 0 0 1.75 1c-.618 1.144-1.075 2.176-1.376 3.062c-.546 1.598-.632 2.848-.593 3.75s.156 1.52.156 1.5l2 .188c.063-.723-.125-1.102-.156-1.782s.027-1.613.5-3c.945-2.769 3.566-7.328 10.375-14.625l-1.438-1.374c-4.082 4.374-6.718 7.785-8.437 10.53a7.7 7.7 0 0 1-1.625-.874C8.059 20.145 7 18.937 7 17c0-2.203.629-3.656 1.656-4.781c1.028-1.125 2.524-1.93 4.313-2.563c3.238-1.144 7.246-1.722 10.687-3C23.976 7.871 25 12.031 25 18.531c0 3.688-.863 5.801-1.875 6.969s-2.23 1.5-3.313 1.5c-1.078 0-2.242-.55-3.25-1.25c-1.007-.7-1.703-1.43-2.406-1.875l-1.062 1.688c.18.113 1.176 1.035 2.344 1.843c1.167.809 2.648 1.594 4.374 1.594c1.54 0 3.422-.543 4.844-2.188C26.078 25.169 27 22.527 27 18.532c0-7.821-1.656-13.438-1.656-13.438z\"/>","width":32,"height":32},
"la:life-ring": {"body":"<path fill=\"currentColor\" d=\"M16 4C9.383 4 4 9.383 4 16s5.383 12 12 12s12-5.383 12-12S22.617 4 16 4m0 2c.336 0 .672.031 1 .063v3.03A7 7 0 0 0 16 9c-.34 0-.672.047-1 .094V6.063A11 11 0 0 1 16 6m-3 .438v3.25A7 7 0 0 0 9.687 13H6.47A9.98 9.98 0 0 1 13 6.437zm6 0A10.07 10.07 0 0 1 25.563 13h-3.25A7 7 0 0 0 19 9.687zM16 11c2.773 0 5 2.227 5 5s-2.227 5-5 5s-5-2.227-5-5s2.227-5 5-5m-9.938 4h3A7 7 0 0 0 9 16c0 .34.047.672.094 1H6.063A11 11 0 0 1 6 16c0-.336.031-.672.063-1zm16.844 0h3.032c.03.328.062.664.062 1s-.031.672-.063 1h-3.03c.046-.328.093-.66.093-1s-.047-.672-.094-1M6.438 19h3.25A7 7 0 0 0 13 22.313v3.25A10.07 10.07 0 0 1 6.437 19zm15.875 0h3.25A10.07 10.07 0 0 1 19 25.563v-3.25A7 7 0 0 0 22.313 19M15 22.906c.328.047.66.094 1 .094s.672-.047 1-.094v3.032A11 11 0 0 1 16 26c-.336 0-.672-.031-1-.063z\"/>","width":32,"height":32}, "la:life-ring": {"body":"<path fill=\"currentColor\" d=\"M16 4C9.383 4 4 9.383 4 16s5.383 12 12 12s12-5.383 12-12S22.617 4 16 4m0 2c.336 0 .672.031 1 .063v3.03A7 7 0 0 0 16 9c-.34 0-.672.047-1 .094V6.063A11 11 0 0 1 16 6m-3 .438v3.25A7 7 0 0 0 9.687 13H6.47A9.98 9.98 0 0 1 13 6.437zm6 0A10.07 10.07 0 0 1 25.563 13h-3.25A7 7 0 0 0 19 9.687zM16 11c2.773 0 5 2.227 5 5s-2.227 5-5 5s-5-2.227-5-5s2.227-5 5-5m-9.938 4h3A7 7 0 0 0 9 16c0 .34.047.672.094 1H6.063A11 11 0 0 1 6 16c0-.336.031-.672.063-1zm16.844 0h3.032c.03.328.062.664.062 1s-.031.672-.063 1h-3.03c.046-.328.093-.66.093-1s-.047-.672-.094-1M6.438 19h3.25A7 7 0 0 0 13 22.313v3.25A10.07 10.07 0 0 1 6.437 19zm15.875 0h3.25A10.07 10.07 0 0 1 19 25.563v-3.25A7 7 0 0 0 22.313 19M15 22.906c.328.047.66.094 1 .094s.672-.047 1-.094v3.032A11 11 0 0 1 16 26c-.336 0-.672-.031-1-.063z\"/>","width":32,"height":32},
@ -158,10 +160,10 @@ export const BUNDLED_ICONS = {
"la:user-minus": {"body":"<path fill=\"currentColor\" d=\"M14 4c-3.9 0-7 3.1-7 7c0 2.4 1.2 4.6 3.1 5.8C6.5 18.3 4 21.9 4 26h2c0-4.4 3.6-8 8-8c1.4 0 2.7.4 3.8 1c-1.1 1.4-1.8 3.1-1.8 5c0 4.4 3.6 8 8 8s8-3.6 8-8s-3.6-8-8-8c-1.7 0-3.4.6-4.7 1.5c-.4-.3-.9-.5-1.4-.7c1.9-1.3 3.1-3.4 3.1-5.8c0-3.9-3.1-7-7-7m0 2c2.8 0 5 2.2 5 5s-2.2 5-5 5s-5-2.2-5-5s2.2-5 5-5m10 12c3.3 0 6 2.7 6 6s-2.7 6-6 6s-6-2.7-6-6s2.7-6 6-6m-4 5v2h8v-2z\"/>","width":32,"height":32}, "la:user-minus": {"body":"<path fill=\"currentColor\" d=\"M14 4c-3.9 0-7 3.1-7 7c0 2.4 1.2 4.6 3.1 5.8C6.5 18.3 4 21.9 4 26h2c0-4.4 3.6-8 8-8c1.4 0 2.7.4 3.8 1c-1.1 1.4-1.8 3.1-1.8 5c0 4.4 3.6 8 8 8s8-3.6 8-8s-3.6-8-8-8c-1.7 0-3.4.6-4.7 1.5c-.4-.3-.9-.5-1.4-.7c1.9-1.3 3.1-3.4 3.1-5.8c0-3.9-3.1-7-7-7m0 2c2.8 0 5 2.2 5 5s-2.2 5-5 5s-5-2.2-5-5s2.2-5 5-5m10 12c3.3 0 6 2.7 6 6s-2.7 6-6 6s-6-2.7-6-6s2.7-6 6-6m-4 5v2h8v-2z\"/>","width":32,"height":32},
"la:user-plus": {"body":"<path fill=\"currentColor\" d=\"M12 2C8.145 2 5 5.145 5 9c0 2.41 1.23 4.55 3.094 5.813C4.527 16.343 2 19.883 2 24h2c0-4.43 3.57-8 8-8c1.375 0 2.656.36 3.781.969A8 8 0 0 0 14 22c0 4.406 3.594 8 8 8s8-3.594 8-8s-3.594-8-8-8a7.96 7.96 0 0 0-4.688 1.531a10 10 0 0 0-1.406-.719A7.02 7.02 0 0 0 19 9c0-3.855-3.145-7-7-7m0 2c2.773 0 5 2.227 5 5s-2.227 5-5 5s-5-2.227-5-5s2.227-5 5-5m10 12c3.324 0 6 2.676 6 6s-2.676 6-6 6s-6-2.676-6-6s2.676-6 6-6m-1 2v3h-3v2h3v3h2v-3h3v-2h-3v-3z\"/>","width":32,"height":32}, "la:user-plus": {"body":"<path fill=\"currentColor\" d=\"M12 2C8.145 2 5 5.145 5 9c0 2.41 1.23 4.55 3.094 5.813C4.527 16.343 2 19.883 2 24h2c0-4.43 3.57-8 8-8c1.375 0 2.656.36 3.781.969A8 8 0 0 0 14 22c0 4.406 3.594 8 8 8s8-3.594 8-8s-3.594-8-8-8a7.96 7.96 0 0 0-4.688 1.531a10 10 0 0 0-1.406-.719A7.02 7.02 0 0 0 19 9c0-3.855-3.145-7-7-7m0 2c2.773 0 5 2.227 5 5s-2.227 5-5 5s-5-2.227-5-5s2.227-5 5-5m10 12c3.324 0 6 2.676 6 6s-2.676 6-6 6s-6-2.676-6-6s2.676-6 6-6m-1 2v3h-3v2h3v3h2v-3h3v-2h-3v-3z\"/>","width":32,"height":32},
"la:user-slash": {"body":"<path fill=\"currentColor\" d=\"M3.7 2.3L2.3 3.7l6.821 6.82l-.021.08l1.9 1.9v-.102L15.602 17H15.5l2.2 2.2c.05.01.096.032.146.044l5.814 5.815c.01.048.03.092.04.14L25.5 27h.102l2.699 2.7l1.398-1.4l-4.105-4.105c-.844-2.88-2.946-5.25-5.694-6.394C21.8 16.5 23 14.4 23 12c0-3.9-3.1-7-7-7c-2.609 0-4.853 1.42-6.078 3.523L3.699 2.301zM16 7c2.8 0 5 2.2 5 5c0 2.087-1.224 3.838-3.006 4.596l-6.59-6.59C12.162 8.224 13.913 7 16 7m-6.9 6.3c.4 1.9 1.4 3.5 3 4.5C8.5 19.3 6 22.9 6 27h2c0-4.1 3-7.4 6.9-7.9z\"/>","width":32,"height":32}, "la:user-slash": {"body":"<path fill=\"currentColor\" d=\"M3.7 2.3L2.3 3.7l6.821 6.82l-.021.08l1.9 1.9v-.102L15.602 17H15.5l2.2 2.2c.05.01.096.032.146.044l5.814 5.815c.01.048.03.092.04.14L25.5 27h.102l2.699 2.7l1.398-1.4l-4.105-4.105c-.844-2.88-2.946-5.25-5.694-6.394C21.8 16.5 23 14.4 23 12c0-3.9-3.1-7-7-7c-2.609 0-4.853 1.42-6.078 3.523L3.699 2.301zM16 7c2.8 0 5 2.2 5 5c0 2.087-1.224 3.838-3.006 4.596l-6.59-6.59C12.162 8.224 13.913 7 16 7m-6.9 6.3c.4 1.9 1.4 3.5 3 4.5C8.5 19.3 6 22.9 6 27h2c0-4.1 3-7.4 6.9-7.9z\"/>","width":32,"height":32},
"la:user-tie": {"body":"<path fill=\"currentColor\" d=\"M16 4c-3.855 0-7 3.145-7 7c0 2.379 1.21 4.484 3.031 5.75C7.926 18.352 5 22.352 5 27h2c0-4.398 3.191-8.074 7.375-8.844L15 20h2l.625-1.844C21.809 18.926 25 22.602 25 27h2c0-4.648-2.926-8.648-7.031-10.25C21.789 15.484 23 13.379 23 11c0-3.855-3.145-7-7-7m0 2c2.773 0 5 2.227 5 5s-2.227 5-5 5s-5-2.227-5-5s2.227-5 5-5m-1 15l-1 6h4l-1-6z\"/>","width":32,"height":32},
"la:users": {"body":"<path fill=\"currentColor\" d=\"M11.5 6A3.514 3.514 0 0 0 8 9.5c0 1.922 1.578 3.5 3.5 3.5S15 11.422 15 9.5S13.422 6 11.5 6m9 0A3.514 3.514 0 0 0 17 9.5c0 1.922 1.578 3.5 3.5 3.5S24 11.422 24 9.5S22.422 6 20.5 6m-9 2c.84 0 1.5.66 1.5 1.5s-.66 1.5-1.5 1.5s-1.5-.66-1.5-1.5s.66-1.5 1.5-1.5m9 0c.84 0 1.5.66 1.5 1.5s-.66 1.5-1.5 1.5s-1.5-.66-1.5-1.5s.66-1.5 1.5-1.5M7 12c-2.2 0-4 1.8-4 4c0 1.113.477 2.117 1.219 2.844A5.04 5.04 0 0 0 2 23h2c0-1.668 1.332-3 3-3s3 1.332 3 3h2a5.04 5.04 0 0 0-2.219-4.156C10.523 18.117 11 17.114 11 16c0-2.2-1.8-4-4-4m5 11c-.625.836-1 1.887-1 3h2c0-1.668 1.332-3 3-3s3 1.332 3 3h2a5.02 5.02 0 0 0-1-3c-.34-.453-.75-.84-1.219-1.156C19.523 21.117 20 20.114 20 19c0-2.2-1.8-4-4-4s-4 1.8-4 4c0 1.113.477 2.117 1.219 2.844A5 5 0 0 0 12 23m8 0h2c0-1.668 1.332-3 3-3s3 1.332 3 3h2a5.04 5.04 0 0 0-2.219-4.156C28.523 18.117 29 17.114 29 16c0-2.2-1.8-4-4-4s-4 1.8-4 4c0 1.113.477 2.117 1.219 2.844A5.04 5.04 0 0 0 20 23M7 14c1.117 0 2 .883 2 2s-.883 2-2 2s-2-.883-2-2s.883-2 2-2m18 0c1.117 0 2 .883 2 2s-.883 2-2 2s-2-.883-2-2s.883-2 2-2m-9 3c1.117 0 2 .883 2 2s-.883 2-2 2s-2-.883-2-2s.883-2 2-2\"/>","width":32,"height":32}, "la:users": {"body":"<path fill=\"currentColor\" d=\"M11.5 6A3.514 3.514 0 0 0 8 9.5c0 1.922 1.578 3.5 3.5 3.5S15 11.422 15 9.5S13.422 6 11.5 6m9 0A3.514 3.514 0 0 0 17 9.5c0 1.922 1.578 3.5 3.5 3.5S24 11.422 24 9.5S22.422 6 20.5 6m-9 2c.84 0 1.5.66 1.5 1.5s-.66 1.5-1.5 1.5s-1.5-.66-1.5-1.5s.66-1.5 1.5-1.5m9 0c.84 0 1.5.66 1.5 1.5s-.66 1.5-1.5 1.5s-1.5-.66-1.5-1.5s.66-1.5 1.5-1.5M7 12c-2.2 0-4 1.8-4 4c0 1.113.477 2.117 1.219 2.844A5.04 5.04 0 0 0 2 23h2c0-1.668 1.332-3 3-3s3 1.332 3 3h2a5.04 5.04 0 0 0-2.219-4.156C10.523 18.117 11 17.114 11 16c0-2.2-1.8-4-4-4m5 11c-.625.836-1 1.887-1 3h2c0-1.668 1.332-3 3-3s3 1.332 3 3h2a5.02 5.02 0 0 0-1-3c-.34-.453-.75-.84-1.219-1.156C19.523 21.117 20 20.114 20 19c0-2.2-1.8-4-4-4s-4 1.8-4 4c0 1.113.477 2.117 1.219 2.844A5 5 0 0 0 12 23m8 0h2c0-1.668 1.332-3 3-3s3 1.332 3 3h2a5.04 5.04 0 0 0-2.219-4.156C28.523 18.117 29 17.114 29 16c0-2.2-1.8-4-4-4s-4 1.8-4 4c0 1.113.477 2.117 1.219 2.844A5.04 5.04 0 0 0 20 23M7 14c1.117 0 2 .883 2 2s-.883 2-2 2s-2-.883-2-2s.883-2 2-2m18 0c1.117 0 2 .883 2 2s-.883 2-2 2s-2-.883-2-2s.883-2 2-2m-9 3c1.117 0 2 .883 2 2s-.883 2-2 2s-2-.883-2-2s.883-2 2-2\"/>","width":32,"height":32},
"la:window-close": {"body":"<path fill=\"currentColor\" d=\"M5 5v22h22V5zm2 2h18v18H7zm4.688 3.313l-1.407 1.406L14.562 16l-4.343 4.344l1.406 1.406l4.344-4.344l4.312 4.313l1.407-1.407L17.375 16l4.25-4.25l-1.406-1.406l-4.25 4.25z\"/>","width":32,"height":32}, "la:window-close": {"body":"<path fill=\"currentColor\" d=\"M5 5v22h22V5zm2 2h18v18H7zm4.688 3.313l-1.407 1.406L14.562 16l-4.343 4.344l1.406 1.406l4.344-4.344l4.312 4.313l1.407-1.407L17.375 16l4.25-4.25l-1.406-1.406l-4.25 4.25z\"/>","width":32,"height":32},
"mdi:account-edit": {"body":"<path fill=\"currentColor\" d=\"m21.7 13.35l-1 1l-2.05-2.05l1-1a.55.55 0 0 1 .77 0l1.28 1.28c.21.21.21.56 0 .77M12 18.94l6.06-6.06l2.05 2.05L14.06 21H12zM12 14c-4.42 0-8 1.79-8 4v2h6v-1.89l4-4c-.66-.08-1.33-.11-2-.11m0-10a4 4 0 0 0-4 4a4 4 0 0 0 4 4a4 4 0 0 0 4-4a4 4 0 0 0-4-4\"/>","width":24,"height":24}, "mdi:account-edit": {"body":"<path fill=\"currentColor\" d=\"m21.7 13.35l-1 1l-2.05-2.05l1-1a.55.55 0 0 1 .77 0l1.28 1.28c.21.21.21.56 0 .77M12 18.94l6.06-6.06l2.05 2.05L14.06 21H12zM12 14c-4.42 0-8 1.79-8 4v2h6v-1.89l4-4c-.66-.08-1.33-.11-2-.11m0-10a4 4 0 0 0-4 4a4 4 0 0 0 4 4a4 4 0 0 0 4-4a4 4 0 0 0-4-4\"/>","width":24,"height":24},
"mdi:account-group": {"body":"<path fill=\"currentColor\" d=\"M12 5.5A3.5 3.5 0 0 1 15.5 9a3.5 3.5 0 0 1-3.5 3.5A3.5 3.5 0 0 1 8.5 9A3.5 3.5 0 0 1 12 5.5M5 8c.56 0 1.08.15 1.53.42c-.15 1.43.27 2.85 1.13 3.96C7.16 13.34 6.16 14 5 14a3 3 0 0 1-3-3a3 3 0 0 1 3-3m14 0a3 3 0 0 1 3 3a3 3 0 0 1-3 3c-1.16 0-2.16-.66-2.66-1.62a5.54 5.54 0 0 0 1.13-3.96c.45-.27.97-.42 1.53-.42M5.5 18.25c0-2.07 2.91-3.75 6.5-3.75s6.5 1.68 6.5 3.75V20h-13zM0 20v-1.5c0-1.39 1.89-2.56 4.45-2.9c-.59.68-.95 1.62-.95 2.65V20zm24 0h-3.5v-1.75c0-1.03-.36-1.97-.95-2.65c2.56.34 4.45 1.51 4.45 2.9z\"/>","width":24,"height":24},
"mdi:alert": {"body":"<path fill=\"currentColor\" d=\"M13 14h-2V9h2m0 9h-2v-2h2M1 21h22L12 2z\"/>","width":24,"height":24}, "mdi:alert": {"body":"<path fill=\"currentColor\" d=\"M13 14h-2V9h2m0 9h-2v-2h2M1 21h22L12 2z\"/>","width":24,"height":24},
"mdi:alert-box": {"body":"<path fill=\"currentColor\" d=\"M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m8 10V7h-2v6zm0 4v-2h-2v2z\"/>","width":24,"height":24}, "mdi:alert-box": {"body":"<path fill=\"currentColor\" d=\"M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m8 10V7h-2v6zm0 4v-2h-2v2z\"/>","width":24,"height":24},
"mdi:alert-box-outline": {"body":"<path fill=\"currentColor\" d=\"M19 19H5V5h14m0-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-8 12h2v2h-2zm0-8h2v6h-2z\"/>","width":24,"height":24}, "mdi:alert-box-outline": {"body":"<path fill=\"currentColor\" d=\"M19 19H5V5h14m0-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-8 12h2v2h-2zm0-8h2v6h-2z\"/>","width":24,"height":24},
@ -190,7 +192,6 @@ export const BUNDLED_ICONS = {
"mdi:code-tags": {"body":"<path fill=\"currentColor\" d=\"m14.6 16.6l4.6-4.6l-4.6-4.6L16 6l6 6l-6 6zm-5.2 0L4.8 12l4.6-4.6L8 6l-6 6l6 6z\"/>","width":24,"height":24}, "mdi:code-tags": {"body":"<path fill=\"currentColor\" d=\"m14.6 16.6l4.6-4.6l-4.6-4.6L16 6l6 6l-6 6zm-5.2 0L4.8 12l4.6-4.6L8 6l-6 6l6 6z\"/>","width":24,"height":24},
"mdi:cog": {"body":"<path fill=\"currentColor\" d=\"M12 15.5A3.5 3.5 0 0 1 8.5 12A3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5a3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97s-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1s.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64z\"/>","width":24,"height":24}, "mdi:cog": {"body":"<path fill=\"currentColor\" d=\"M12 15.5A3.5 3.5 0 0 1 8.5 12A3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5a3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97s-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1s.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64z\"/>","width":24,"height":24},
"mdi:database-refresh": {"body":"<path fill=\"currentColor\" d=\"M12 3c4.42 0 8 1.79 8 4s-3.58 4-8 4s-8-1.79-8-4s3.58-4 8-4M4 9c0 2.21 3.58 4 8 4c1.11 0 2.18-.11 3.14-.32c-.95.86-1.64 1.99-1.96 3.28L12 16c-4.42 0-8-1.79-8-4zm16 0v2h-.5l-.6.03c.7-.6 1.1-1.29 1.1-2.03M4 14c0 2.21 3.58 4 8 4l1-.03c.09 1.06.42 2.03.95 2.91L12 21c-4.42 0-8-1.79-8-4zm15-.5c1.11 0 2.11.45 2.83 1.17L23 13.5v4h-4l1.77-1.77A2.5 2.5 0 1 0 21 19h1.71A3.99 3.99 0 0 1 19 21.5c-2.21 0-4-1.79-4-4s1.79-4 4-4\"/>","width":24,"height":24}, "mdi:database-refresh": {"body":"<path fill=\"currentColor\" d=\"M12 3c4.42 0 8 1.79 8 4s-3.58 4-8 4s-8-1.79-8-4s3.58-4 8-4M4 9c0 2.21 3.58 4 8 4c1.11 0 2.18-.11 3.14-.32c-.95.86-1.64 1.99-1.96 3.28L12 16c-4.42 0-8-1.79-8-4zm16 0v2h-.5l-.6.03c.7-.6 1.1-1.29 1.1-2.03M4 14c0 2.21 3.58 4 8 4l1-.03c.09 1.06.42 2.03.95 2.91L12 21c-4.42 0-8-1.79-8-4zm15-.5c1.11 0 2.11.45 2.83 1.17L23 13.5v4h-4l1.77-1.77A2.5 2.5 0 1 0 21 19h1.71A3.99 3.99 0 0 1 19 21.5c-2.21 0-4-1.79-4-4s1.79-4 4-4\"/>","width":24,"height":24},
"mdi:dice-5": {"body":"<path fill=\"currentColor\" d=\"M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m2 2a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m10 10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m0-10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m-5 5a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m-5 5a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2\"/>","width":24,"height":24},
"mdi:dog": {"body":"<path fill=\"currentColor\" d=\"M18 4c-1.71 0-2.75.33-3.35.61C13.88 4.23 13 4 12 4s-1.88.23-2.65.61C8.75 4.33 7.71 4 6 4c-3 0-5 8-5 10c0 .83 1.32 1.59 3.14 1.9c.64 2.24 3.66 3.95 7.36 4.1v-4.28c-.59-.37-1.5-1.04-1.5-1.72c0-1 2-1 2-1s2 0 2 1c0 .68-.91 1.35-1.5 1.72V20c3.7-.15 6.72-1.86 7.36-4.1C21.68 15.59 23 14.83 23 14c0-2-2-10-5-10M4.15 13.87c-.5-.12-.89-.26-1.15-.37c.25-2.77 2.2-7.1 3.05-7.5c.54 0 .95.06 1.32.11c-2.1 2.31-2.93 5.93-3.22 7.76M9 12a1 1 0 0 1-1-1c0-.54.45-1 1-1a1 1 0 0 1 1 1c0 .56-.45 1-1 1m6 0a1 1 0 0 1-1-1c0-.54.45-1 1-1a1 1 0 0 1 1 1c0 .56-.45 1-1 1m4.85 1.87c-.29-1.83-1.12-5.45-3.22-7.76c.37-.05.78-.11 1.32-.11c.85.4 2.8 4.73 3.05 7.5c-.25.11-.64.25-1.15.37\"/>","width":24,"height":24}, "mdi:dog": {"body":"<path fill=\"currentColor\" d=\"M18 4c-1.71 0-2.75.33-3.35.61C13.88 4.23 13 4 12 4s-1.88.23-2.65.61C8.75 4.33 7.71 4 6 4c-3 0-5 8-5 10c0 .83 1.32 1.59 3.14 1.9c.64 2.24 3.66 3.95 7.36 4.1v-4.28c-.59-.37-1.5-1.04-1.5-1.72c0-1 2-1 2-1s2 0 2 1c0 .68-.91 1.35-1.5 1.72V20c3.7-.15 6.72-1.86 7.36-4.1C21.68 15.59 23 14.83 23 14c0-2-2-10-5-10M4.15 13.87c-.5-.12-.89-.26-1.15-.37c.25-2.77 2.2-7.1 3.05-7.5c.54 0 .95.06 1.32.11c-2.1 2.31-2.93 5.93-3.22 7.76M9 12a1 1 0 0 1-1-1c0-.54.45-1 1-1a1 1 0 0 1 1 1c0 .56-.45 1-1 1m6 0a1 1 0 0 1-1-1c0-.54.45-1 1-1a1 1 0 0 1 1 1c0 .56-.45 1-1 1m4.85 1.87c-.29-1.83-1.12-5.45-3.22-7.76c.37-.05.78-.11 1.32-.11c.85.4 2.8 4.73 3.05 7.5c-.25.11-.64.25-1.15.37\"/>","width":24,"height":24},
"mdi:drag-horizontal": {"body":"<path fill=\"currentColor\" d=\"M3 15v-2h2v2zm0-4V9h2v2zm4 4v-2h2v2zm0-4V9h2v2zm4 4v-2h2v2zm0-4V9h2v2zm4 4v-2h2v2zm0-4V9h2v2zm4 4v-2h2v2zm0-4V9h2v2z\"/>","width":24,"height":24}, "mdi:drag-horizontal": {"body":"<path fill=\"currentColor\" d=\"M3 15v-2h2v2zm0-4V9h2v2zm4 4v-2h2v2zm0-4V9h2v2zm4 4v-2h2v2zm0-4V9h2v2zm4 4v-2h2v2zm0-4V9h2v2zm4 4v-2h2v2zm0-4V9h2v2z\"/>","width":24,"height":24},
"mdi:emoticon-outline": {"body":"<path fill=\"currentColor\" d=\"M12 17.5c2.33 0 4.3-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5M8.5 11A1.5 1.5 0 0 0 10 9.5A1.5 1.5 0 0 0 8.5 8A1.5 1.5 0 0 0 7 9.5A1.5 1.5 0 0 0 8.5 11m7 0A1.5 1.5 0 0 0 17 9.5A1.5 1.5 0 0 0 15.5 8A1.5 1.5 0 0 0 14 9.5a1.5 1.5 0 0 0 1.5 1.5M12 20a8 8 0 0 1-8-8a8 8 0 0 1 8-8a8 8 0 0 1 8 8a8 8 0 0 1-8 8m0-18C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2\"/>","width":24,"height":24}, "mdi:emoticon-outline": {"body":"<path fill=\"currentColor\" d=\"M12 17.5c2.33 0 4.3-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5M8.5 11A1.5 1.5 0 0 0 10 9.5A1.5 1.5 0 0 0 8.5 8A1.5 1.5 0 0 0 7 9.5A1.5 1.5 0 0 0 8.5 11m7 0A1.5 1.5 0 0 0 17 9.5A1.5 1.5 0 0 0 15.5 8A1.5 1.5 0 0 0 14 9.5a1.5 1.5 0 0 0 1.5 1.5M12 20a8 8 0 0 1-8-8a8 8 0 0 1 8-8a8 8 0 0 1 8 8a8 8 0 0 1-8 8m0-18C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2\"/>","width":24,"height":24},
@ -228,7 +229,6 @@ export const BUNDLED_ICONS = {
"mdi:format-strikethrough": {"body":"<path fill=\"currentColor\" d=\"M3 14h18v-2H3m2-8v3h5v3h4V7h5V4m-9 15h4v-3h-4z\"/>","width":24,"height":24}, "mdi:format-strikethrough": {"body":"<path fill=\"currentColor\" d=\"M3 14h18v-2H3m2-8v3h5v3h4V7h5V4m-9 15h4v-3h-4z\"/>","width":24,"height":24},
"mdi:format-subscript": {"body":"<path fill=\"currentColor\" d=\"M16 7.41L11.41 12L16 16.59L14.59 18L10 13.41L5.41 18L4 16.59L8.59 12L4 7.41L5.41 6L10 10.59L14.59 6zm5.85 13.62h-4.88v-1l.89-.8c.76-.65 1.32-1.19 1.7-1.63c.37-.44.56-.85.57-1.24a.9.9 0 0 0-.27-.7c-.18-.16-.47-.28-.86-.28c-.31 0-.58.06-.84.18l-.66.38l-.45-1.17c.27-.21.59-.39.98-.53s.82-.24 1.29-.24c.78.04 1.38.25 1.78.66s.62.93.62 1.57c-.01.56-.19 1.08-.54 1.55c-.34.47-.76.92-1.27 1.36l-.64.52v.02h2.58z\"/>","width":24,"height":24}, "mdi:format-subscript": {"body":"<path fill=\"currentColor\" d=\"M16 7.41L11.41 12L16 16.59L14.59 18L10 13.41L5.41 18L4 16.59L8.59 12L4 7.41L5.41 6L10 10.59L14.59 6zm5.85 13.62h-4.88v-1l.89-.8c.76-.65 1.32-1.19 1.7-1.63c.37-.44.56-.85.57-1.24a.9.9 0 0 0-.27-.7c-.18-.16-.47-.28-.86-.28c-.31 0-.58.06-.84.18l-.66.38l-.45-1.17c.27-.21.59-.39.98-.53s.82-.24 1.29-.24c.78.04 1.38.25 1.78.66s.62.93.62 1.57c-.01.56-.19 1.08-.54 1.55c-.34.47-.76.92-1.27 1.36l-.64.52v.02h2.58z\"/>","width":24,"height":24},
"mdi:format-superscript": {"body":"<path fill=\"currentColor\" d=\"M16 7.41L11.41 12L16 16.59L14.59 18L10 13.41L5.41 18L4 16.59L8.59 12L4 7.41L5.41 6L10 10.59L14.59 6zM21.85 9h-4.88V8l.89-.82c.76-.64 1.32-1.18 1.7-1.63q.555-.66.57-1.23a.88.88 0 0 0-.27-.7c-.18-.19-.47-.28-.86-.29c-.31.01-.58.07-.84.17l-.66.39l-.45-1.17c.27-.22.59-.39.98-.53S18.85 2 19.32 2c.78 0 1.38.2 1.78.61c.4.39.62.93.62 1.57c-.01.56-.19 1.08-.54 1.55c-.34.48-.76.93-1.27 1.36l-.64.52v.02h2.58z\"/>","width":24,"height":24}, "mdi:format-superscript": {"body":"<path fill=\"currentColor\" d=\"M16 7.41L11.41 12L16 16.59L14.59 18L10 13.41L5.41 18L4 16.59L8.59 12L4 7.41L5.41 6L10 10.59L14.59 6zM21.85 9h-4.88V8l.89-.82c.76-.64 1.32-1.18 1.7-1.63q.555-.66.57-1.23a.88.88 0 0 0-.27-.7c-.18-.19-.47-.28-.86-.29c-.31.01-.58.07-.84.17l-.66.39l-.45-1.17c.27-.22.59-.39.98-.53S18.85 2 19.32 2c.78 0 1.38.2 1.78.61c.4.39.62.93.62 1.57c-.01.56-.19 1.08-.54 1.55c-.34.48-.76.93-1.27 1.36l-.64.52v.02h2.58z\"/>","width":24,"height":24},
"mdi:format-title": {"body":"<path fill=\"currentColor\" d=\"M5 4v3h5.5v12h3V7H19V4z\"/>","width":24,"height":24},
"mdi:format-underline": {"body":"<path fill=\"currentColor\" d=\"M5 21h14v-2H5zm7-4a6 6 0 0 0 6-6V3h-2.5v8a3.5 3.5 0 0 1-3.5 3.5A3.5 3.5 0 0 1 8.5 11V3H6v8a6 6 0 0 0 6 6\"/>","width":24,"height":24}, "mdi:format-underline": {"body":"<path fill=\"currentColor\" d=\"M5 21h14v-2H5zm7-4a6 6 0 0 0 6-6V3h-2.5v8a3.5 3.5 0 0 1-3.5 3.5A3.5 3.5 0 0 1 8.5 11V3H6v8a6 6 0 0 0 6 6\"/>","width":24,"height":24},
"mdi:hand-wave-outline": {"body":"<path fill=\"currentColor\" d=\"M7.03 4.95L3.5 8.5c-3.33 3.31-3.33 8.69 0 12s8.69 3.33 12 0l6-6c1-.97 1-2.56 0-3.54c-.1-.12-.23-.23-.37-.32l.37-.39c1-.97 1-2.56 0-3.54c-.14-.16-.33-.3-.5-.41c.38-.92.21-2.02-.54-2.77c-.87-.87-2.22-.96-3.2-.28a2.517 2.517 0 0 0-3.88-.42l-2.51 2.51c-.09-.14-.2-.27-.32-.39a2.53 2.53 0 0 0-3.52 0m1.41 1.42c.2-.2.51-.2.71 0s.2.51 0 .71l-3.18 3.18a3 3 0 0 1 0 4.24l1.41 1.41a5 5 0 0 0 1.12-5.36l6.3-6.3c.2-.2.51-.2.7 0s.21.51 0 .71l-4.59 4.6l1.41 1.41l6.01-6.01c.2-.2.51-.2.71 0s.2.51 0 .71l-6.01 6.01l1.41 1.41l4.95-4.95c.2-.2.51-.2.71 0s.2.51 0 .71l-5.66 5.65l1.41 1.42l3.54-3.54c.2-.2.51-.2.71 0s.2.51 0 .71l-6 6.01c-2.54 2.54-6.65 2.54-9.19 0s-2.54-6.65 0-9.19zM23 17c0 3.31-2.69 6-6 6v-1.5c2.5 0 4.5-2 4.5-4.5zM1 7c0-3.31 2.69-6 6-6v1.5c-2.5 0-4.5 2-4.5 4.5z\"/>","width":24,"height":24}, "mdi:hand-wave-outline": {"body":"<path fill=\"currentColor\" d=\"M7.03 4.95L3.5 8.5c-3.33 3.31-3.33 8.69 0 12s8.69 3.33 12 0l6-6c1-.97 1-2.56 0-3.54c-.1-.12-.23-.23-.37-.32l.37-.39c1-.97 1-2.56 0-3.54c-.14-.16-.33-.3-.5-.41c.38-.92.21-2.02-.54-2.77c-.87-.87-2.22-.96-3.2-.28a2.517 2.517 0 0 0-3.88-.42l-2.51 2.51c-.09-.14-.2-.27-.32-.39a2.53 2.53 0 0 0-3.52 0m1.41 1.42c.2-.2.51-.2.71 0s.2.51 0 .71l-3.18 3.18a3 3 0 0 1 0 4.24l1.41 1.41a5 5 0 0 0 1.12-5.36l6.3-6.3c.2-.2.51-.2.7 0s.21.51 0 .71l-4.59 4.6l1.41 1.41l6.01-6.01c.2-.2.51-.2.71 0s.2.51 0 .71l-6.01 6.01l1.41 1.41l4.95-4.95c.2-.2.51-.2.71 0s.2.51 0 .71l-5.66 5.65l1.41 1.42l3.54-3.54c.2-.2.51-.2.71 0s.2.51 0 .71l-6 6.01c-2.54 2.54-6.65 2.54-9.19 0s-2.54-6.65 0-9.19zM23 17c0 3.31-2.69 6-6 6v-1.5c2.5 0 4.5-2 4.5-4.5zM1 7c0-3.31 2.69-6 6-6v1.5c-2.5 0-4.5 2-4.5 4.5z\"/>","width":24,"height":24},
"mdi:highlight-off": {"body":"<path fill=\"currentColor\" d=\"M12 20c-4.41 0-8-3.59-8-8s3.59-8 8-8s8 3.59 8 8s-3.59 8-8 8m0-18C6.47 2 2 6.47 2 12s4.47 10 10 10s10-4.47 10-10S17.53 2 12 2m2.59 6L12 10.59L9.41 8L8 9.41L10.59 12L8 14.59L9.41 16L12 13.41L14.59 16L16 14.59L13.41 12L16 9.41z\"/>","width":24,"height":24}, "mdi:highlight-off": {"body":"<path fill=\"currentColor\" d=\"M12 20c-4.41 0-8-3.59-8-8s3.59-8 8-8s8 3.59 8 8s-3.59 8-8 8m0-18C6.47 2 2 6.47 2 12s4.47 10 10 10s10-4.47 10-10S17.53 2 12 2m2.59 6L12 10.59L9.41 8L8 9.41L10.59 12L8 14.59L9.41 16L12 13.41L14.59 16L16 14.59L13.41 12L16 9.41z\"/>","width":24,"height":24},
@ -248,7 +248,6 @@ export const BUNDLED_ICONS = {
"mdi:logout": {"body":"<path fill=\"currentColor\" d=\"m17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5M4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4z\"/>","width":24,"height":24}, "mdi:logout": {"body":"<path fill=\"currentColor\" d=\"m17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5M4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4z\"/>","width":24,"height":24},
"mdi:menu-down": {"body":"<path fill=\"currentColor\" d=\"m7 10l5 5l5-5z\"/>","width":24,"height":24}, "mdi:menu-down": {"body":"<path fill=\"currentColor\" d=\"m7 10l5 5l5-5z\"/>","width":24,"height":24},
"mdi:message-alert": {"body":"<path fill=\"currentColor\" d=\"M13 11h-2V5h2m0 10h-2v-2h2m7-11H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2\"/>","width":24,"height":24}, "mdi:message-alert": {"body":"<path fill=\"currentColor\" d=\"M13 11h-2V5h2m0 10h-2v-2h2m7-11H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2\"/>","width":24,"height":24},
"mdi:near-me": {"body":"<path fill=\"currentColor\" d=\"M21 3L3 10.53v.97l6.84 2.66L12.5 21h.96z\"/>","width":24,"height":24},
"mdi:palette": {"body":"<path fill=\"currentColor\" d=\"M17.5 12a1.5 1.5 0 0 1-1.5-1.5A1.5 1.5 0 0 1 17.5 9a1.5 1.5 0 0 1 1.5 1.5a1.5 1.5 0 0 1-1.5 1.5m-3-4A1.5 1.5 0 0 1 13 6.5A1.5 1.5 0 0 1 14.5 5A1.5 1.5 0 0 1 16 6.5A1.5 1.5 0 0 1 14.5 8m-5 0A1.5 1.5 0 0 1 8 6.5A1.5 1.5 0 0 1 9.5 5A1.5 1.5 0 0 1 11 6.5A1.5 1.5 0 0 1 9.5 8m-3 4A1.5 1.5 0 0 1 5 10.5A1.5 1.5 0 0 1 6.5 9A1.5 1.5 0 0 1 8 10.5A1.5 1.5 0 0 1 6.5 12M12 3a9 9 0 0 0-9 9a9 9 0 0 0 9 9a1.5 1.5 0 0 0 1.5-1.5c0-.39-.15-.74-.39-1c-.23-.27-.38-.62-.38-1a1.5 1.5 0 0 1 1.5-1.5H16a5 5 0 0 0 5-5c0-4.42-4.03-8-9-8\"/>","width":24,"height":24}, "mdi:palette": {"body":"<path fill=\"currentColor\" d=\"M17.5 12a1.5 1.5 0 0 1-1.5-1.5A1.5 1.5 0 0 1 17.5 9a1.5 1.5 0 0 1 1.5 1.5a1.5 1.5 0 0 1-1.5 1.5m-3-4A1.5 1.5 0 0 1 13 6.5A1.5 1.5 0 0 1 14.5 5A1.5 1.5 0 0 1 16 6.5A1.5 1.5 0 0 1 14.5 8m-5 0A1.5 1.5 0 0 1 8 6.5A1.5 1.5 0 0 1 9.5 5A1.5 1.5 0 0 1 11 6.5A1.5 1.5 0 0 1 9.5 8m-3 4A1.5 1.5 0 0 1 5 10.5A1.5 1.5 0 0 1 6.5 9A1.5 1.5 0 0 1 8 10.5A1.5 1.5 0 0 1 6.5 12M12 3a9 9 0 0 0-9 9a9 9 0 0 0 9 9a1.5 1.5 0 0 0 1.5-1.5c0-.39-.15-.74-.39-1c-.23-.27-.38-.62-.38-1a1.5 1.5 0 0 1 1.5-1.5H16a5 5 0 0 0 5-5c0-4.42-4.03-8-9-8\"/>","width":24,"height":24},
"mdi:percent-outline": {"body":"<path fill=\"currentColor\" d=\"m18.5 3.5l2 2l-15 15l-2-2zM7 4c1.66 0 3 1.34 3 3s-1.34 3-3 3s-3-1.34-3-3s1.34-3 3-3m10 10c1.66 0 3 1.34 3 3s-1.34 3-3 3s-3-1.34-3-3s1.34-3 3-3M7 6c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m10 10c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1\"/>","width":24,"height":24}, "mdi:percent-outline": {"body":"<path fill=\"currentColor\" d=\"m18.5 3.5l2 2l-15 15l-2-2zM7 4c1.66 0 3 1.34 3 3s-1.34 3-3 3s-3-1.34-3-3s1.34-3 3-3m10 10c1.66 0 3 1.34 3 3s-1.34 3-3 3s-3-1.34-3-3s1.34-3 3-3M7 6c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m10 10c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1\"/>","width":24,"height":24},
"mdi:play": {"body":"<path fill=\"currentColor\" d=\"M8 5.14v14l11-7z\"/>","width":24,"height":24}, "mdi:play": {"body":"<path fill=\"currentColor\" d=\"M8 5.14v14l11-7z\"/>","width":24,"height":24},
@ -279,6 +278,5 @@ export const BUNDLED_ICONS = {
"mdi:transfer-down": {"body":"<path fill=\"currentColor\" d=\"M16 3v2H8V3zm0 4v2H8V7zm0 4v2H8v-2zM5 15h14l-7 7z\"/>","width":24,"height":24}, "mdi:transfer-down": {"body":"<path fill=\"currentColor\" d=\"M16 3v2H8V3zm0 4v2H8V7zm0 4v2H8v-2zM5 15h14l-7 7z\"/>","width":24,"height":24},
"mdi:transfer-up": {"body":"<path fill=\"currentColor\" d=\"M8 21v-2h8v2zm0-4v-2h8v2zm0-4v-2h8v2zm11-4H5l7-7z\"/>","width":24,"height":24}, "mdi:transfer-up": {"body":"<path fill=\"currentColor\" d=\"M8 21v-2h8v2zm0-4v-2h8v2zm0-4v-2h8v2zm11-4H5l7-7z\"/>","width":24,"height":24},
"mdi:undo-variant": {"body":"<path fill=\"currentColor\" d=\"M13.5 7a6.5 6.5 0 0 1 6.5 6.5a6.5 6.5 0 0 1-6.5 6.5H10v-2h3.5c2.5 0 4.5-2 4.5-4.5S16 9 13.5 9H7.83l3.08 3.09L9.5 13.5L4 8l5.5-5.5l1.42 1.41L7.83 7zM6 18h2v2H6z\"/>","width":24,"height":24}, "mdi:undo-variant": {"body":"<path fill=\"currentColor\" d=\"M13.5 7a6.5 6.5 0 0 1 6.5 6.5a6.5 6.5 0 0 1-6.5 6.5H10v-2h3.5c2.5 0 4.5-2 4.5-4.5S16 9 13.5 9H7.83l3.08 3.09L9.5 13.5L4 8l5.5-5.5l1.42 1.41L7.83 7zM6 18h2v2H6z\"/>","width":24,"height":24},
"mdi:view-split-vertical": {"body":"<path fill=\"currentColor\" d=\"M13 5h8v14h-8zM3 5h8v2H3zm0 6V9h8v2zm0 8v-2h8v2zm0-4v-2h8v2z\"/>","width":24,"height":24}, "mdi:view-split-vertical": {"body":"<path fill=\"currentColor\" d=\"M13 5h8v14h-8zM3 5h8v2H3zm0 6V9h8v2zm0 8v-2h8v2zm0-4v-2h8v2z\"/>","width":24,"height":24}
"mdi:web": {"body":"<path fill=\"currentColor\" d=\"M16.36 14c.08-.66.14-1.32.14-2s-.06-1.34-.14-2h3.38c.16.64.26 1.31.26 2s-.1 1.36-.26 2m-5.15 5.56c.6-1.11 1.06-2.31 1.38-3.56h2.95a8.03 8.03 0 0 1-4.33 3.56M14.34 14H9.66c-.1-.66-.16-1.32-.16-2s.06-1.35.16-2h4.68c.09.65.16 1.32.16 2s-.07 1.34-.16 2M12 19.96c-.83-1.2-1.5-2.53-1.91-3.96h3.82c-.41 1.43-1.08 2.76-1.91 3.96M8 8H5.08A7.92 7.92 0 0 1 9.4 4.44C8.8 5.55 8.35 6.75 8 8m-2.92 8H8c.35 1.25.8 2.45 1.4 3.56A8 8 0 0 1 5.08 16m-.82-2C4.1 13.36 4 12.69 4 12s.1-1.36.26-2h3.38c-.08.66-.14 1.32-.14 2s.06 1.34.14 2M12 4.03c.83 1.2 1.5 2.54 1.91 3.97h-3.82c.41-1.43 1.08-2.77 1.91-3.97M18.92 8h-2.95a15.7 15.7 0 0 0-1.38-3.56c1.84.63 3.37 1.9 4.33 3.56M12 2C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2\"/>","width":24,"height":24}
} }

@ -83,7 +83,7 @@
:rules="groupNameValidation" :rules="groupNameValidation"
hide-bottom-space hide-bottom-space
:aria-label="t(`admin.groups.name`)" :aria-label="t(`admin.groups.name`)"
:disable="isGuestGroup" /> :disable="isGuestGroup || !canManage" />
</w-item-section> </w-item-section>
</w-item> </w-item>
</w-card> </w-card>
@ -100,6 +100,7 @@
outlined outlined
v-model="state.group.redirectOnLogin" v-model="state.group.redirectOnLogin"
dense dense
:disable="!canManage"
:aria-label="t(`admin.groups.redirectOnLogin`)" /> :aria-label="t(`admin.groups.redirectOnLogin`)" />
</w-item-section> </w-item-section>
</w-item> </w-item>
@ -117,6 +118,7 @@
outlined outlined
v-model="state.group.redirectOnFirstLogin" v-model="state.group.redirectOnFirstLogin"
dense dense
:disable="!canManage"
:aria-label="t(`admin.groups.redirectOnLogin`)" /> :aria-label="t(`admin.groups.redirectOnLogin`)" />
</w-item-section> </w-item-section>
</w-item> </w-item>
@ -134,6 +136,7 @@
outlined outlined
v-model="state.group.redirectOnLogout" v-model="state.group.redirectOnLogout"
dense dense
:disable="!canManage"
:aria-label="t(`admin.groups.redirectOnLogout`)" /> :aria-label="t(`admin.groups.redirectOnLogout`)" />
</w-item-section> </w-item-section>
</w-item> </w-item>
@ -192,7 +195,10 @@
color="grey" color="grey"
type="a" type="a"
:href="siteStore.docsBase + `/admin/permissions#page-rules`" :href="siteStore.docsBase + `/admin/permissions#page-rules`"
target="_blank" /> target="_blank"
:aria-label="t(`common.actions.viewDocs`)">
<w-tooltip>{{ t(`common.actions.viewDocs`) }}</w-tooltip>
</w-btn>
<w-btn <w-btn
class="acrylic-btn mr-2" class="acrylic-btn mr-2"
flat flat
@ -233,7 +239,7 @@
<w-icon <w-icon
:name="getRuleModeIcon(rule.mode)" :name="getRuleModeIcon(rule.mode)"
color="white" color="white"
@click="rule.mode = getNextRuleMode(rule.mode)" /> @click="cycleRuleMode(rule)" />
</div> </div>
<div class="admin-groups-rule-name"> <div class="admin-groups-rule-name">
<div class="admin-groups-rule-name-text"> <div class="admin-groups-rule-name-text">
@ -242,7 +248,11 @@
}}</strong> }}</strong>
</div> </div>
<w-separator class="ml-2 mr-1" vertical /> <w-separator class="ml-2 mr-1" vertical />
<input type="text" v-model="rule.name" placeholder="Rule Name" /> <input
type="text"
v-model="rule.name"
placeholder="Rule Name"
:disabled="!canManage" />
</div> </div>
<w-card class="admin-groups-rule-card mt-4" flat> <w-card class="admin-groups-rule-card mt-4" flat>
<w-card-section <w-card-section
@ -257,6 +267,7 @@
dense dense
:aria-label="t(`admin.groups.ruleSites`)" :aria-label="t(`admin.groups.ruleSites`)"
:options="ruleOptions" :options="ruleOptions"
:disable="!canManage"
placeholder="Select permissions..." placeholder="Select permissions..."
option-value="permission" option-value="permission"
option-label="title" option-label="title"
@ -319,6 +330,7 @@
emit-value emit-value
map-options map-options
dense dense
:disable="!canManage"
:aria-label="t(`admin.groups.ruleSites`)" :aria-label="t(`admin.groups.ruleSites`)"
:options="adminStore.sites" :options="adminStore.sites"
option-value="id" option-value="id"
@ -354,6 +366,7 @@
emit-value emit-value
map-options map-options
dense dense
:disable="!canManage"
:aria-label="t(`admin.groups.ruleLocales`)" :aria-label="t(`admin.groups.ruleLocales`)"
:options="adminStore.locales" :options="adminStore.locales"
option-value="code" option-value="code"
@ -399,6 +412,7 @@
emit-value emit-value
map-options map-options
dense dense
:disable="!canManage"
:aria-label="t(`admin.groups.ruleMatch`)" :aria-label="t(`admin.groups.ruleMatch`)"
:options="[ :options="[
{ label: t('admin.groups.ruleMatchStart'), value: 'START' }, { label: t('admin.groups.ruleMatchStart'), value: 'START' },
@ -430,6 +444,7 @@
hide-dropdown-icon hide-dropdown-icon
:placeholder="t(`admin.groups.ruleTagsHint`)" :placeholder="t(`admin.groups.ruleTagsHint`)"
:aria-label="t(`admin.groups.ruleTags`)" :aria-label="t(`admin.groups.ruleTags`)"
:disable="!canManage"
:loading="state.isLoadingTags" :loading="state.isLoadingTags"
@create="(val) => addRuleTags(rule, val)"> @create="(val) => addRuleTags(rule, val)">
<template #prepend><w-icon name="la:hashtag" size="xs" /></template> <template #prepend><w-icon name="la:hashtag" size="xs" /></template>
@ -444,6 +459,7 @@
[`START`, `SUBTREE`, `REGEX`, `EXACT`].includes(rule.match) ? `/` : null [`START`, `SUBTREE`, `REGEX`, `EXACT`].includes(rule.match) ? `/` : null
" "
:suffix="rule.match === `REGEX` ? `/` : null" :suffix="rule.match === `REGEX` ? `/` : null"
:disable="!canManage"
:aria-label="t(`admin.groups.rulePath`)" /> :aria-label="t(`admin.groups.rulePath`)" />
</w-card-section> </w-card-section>
</w-card-section> </w-card-section>
@ -459,10 +475,20 @@
<w-page v-else-if="route.params.section === `permissions`"> <w-page v-else-if="route.params.section === `permissions`">
<div class="p-4"> <div class="p-4">
<div class="grid grid-cols-12 gap-4"> <div class="grid grid-cols-12 gap-4">
<!--
One card per kind of question, rather than one list of ten: what an account gets for
being let into the admin area at all, what runs a site, what runs the people. The row
inside them is the same everywhere, so it is written once and the cards are data --
see `permissionCards`.
-->
<div class="col-span-12 lg:col-span-6"> <div class="col-span-12 lg:col-span-6">
<w-card class="shadow-1 pb-2"> <w-card
v-for="(card, cardIdx) of leftPermissionCards"
:key="card.key"
class="shadow-1 pb-2"
:class="{ 'mt-4': cardIdx > 0 }">
<w-card-header> <w-card-header>
{{ t(`admin.groups.permissions`) }} {{ t(card.title) }}
<template #action> <template #action>
<w-btn <w-btn
class="acrylic-btn" class="acrylic-btn"
@ -470,14 +496,17 @@
flat flat
color="grey" color="grey"
type="a" type="a"
:href="siteStore.docsBase + `/admin/permissions#global-permissions`" :href="siteStore.docsBase + card.docs"
target="_blank" /> target="_blank"
:aria-label="t(`common.actions.viewDocs`)">
<w-tooltip>{{ t(`common.actions.viewDocs`) }}</w-tooltip>
</w-btn>
</template> </template>
</w-card-header> </w-card-header>
<template v-for="(perm, idx) of permissions" :key="perm.permission"> <template v-for="(perm, idx) of card.permissions" :key="perm.permission">
<w-item tag="label"> <w-item tag="label">
<w-item-section class="items-center" style="flex: 0 0 40px"> <w-item-section class="items-center" style="flex: 0 0 40px">
<w-icon name="la:snowflake" color="primary" size="sm" /> <w-icon :name="card.icon" color="primary" size="sm" />
</w-item-section> </w-item-section>
<w-item-section> <w-item-section>
<w-item-label>{{ perm.permission }}</w-item-label> <w-item-label>{{ perm.permission }}</w-item-label>
@ -490,26 +519,88 @@
color="primary" color="primary"
checked-icon="la:check" checked-icon="la:check"
unchecked-icon="la:times" unchecked-icon="la:times"
:disable="isSystemPermissionLocked(perm.permission)" :disable="
:aria-label="t(`admin.general.allowComments`)" /> isSystemPermissionLocked(perm.permission) || !canManagePermissions
"
:aria-label="perm.permission" />
</w-item-section> </w-item-section>
</w-item> </w-item>
<w-separator class="my-2" inset v-if="idx < permissions.length - 1" /> <w-separator class="my-2" inset v-if="idx < card.permissions.length - 1" />
</template> </template>
</w-card> </w-card>
</div> </div>
<div class="col-span-12 lg:col-span-6">
<w-card
v-for="card of rightPermissionCards"
:key="card.key"
class="shadow-1 mb-4 pb-2">
<w-card-header>
{{ t(card.title) }}
<template #action>
<w-btn
class="acrylic-btn"
icon="la:question-circle"
flat
color="grey"
type="a"
:href="siteStore.docsBase + card.docs"
target="_blank"
:aria-label="t(`common.actions.viewDocs`)">
<w-tooltip>{{ t(`common.actions.viewDocs`) }}</w-tooltip>
</w-btn>
</template>
</w-card-header>
<template v-for="(perm, idx) of card.permissions" :key="perm.permission">
<w-item tag="label">
<w-item-section class="items-center" style="flex: 0 0 40px">
<w-icon :name="card.icon" color="primary" size="sm" />
</w-item-section>
<w-item-section>
<w-item-label>{{ perm.permission }}</w-item-label>
<w-item-label caption>{{ perm.hint }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
v-model="state.group.permissions"
:val="perm.permission"
color="primary"
checked-icon="la:check"
unchecked-icon="la:times"
:disable="
isSystemPermissionLocked(perm.permission) || !canManagePermissions
"
:aria-label="perm.permission" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset v-if="idx < card.permissions.length - 1" />
</template>
</w-card>
<!-- <!--
`manage:system` on a card of its own, because it is not one more thing a group may do: `manage:system` on a card of its own, because it is not one more thing a group may
the server checks it FIRST and lets the request through whatever the list on the left do: the server checks it FIRST and lets the request through whatever the cards beside
says, so ticking it makes every toggle beside it moot. A row at the bottom of that list it say, so ticking it makes every other toggle on this screen moot. A row in any of
would have read as the tenth of ten. those lists would have read as one more item in it.
--> -->
<div class="col-span-12 lg:col-span-6">
<w-card class="shadow-1 pb-2"> <w-card class="shadow-1 pb-2">
<w-card-header>{{ t(`admin.groups.systemPermission`) }}</w-card-header> <w-card-header>
{{ t(`admin.groups.systemPermission`) }}
<template #action>
<w-btn
class="acrylic-btn"
icon="la:question-circle"
flat
color="grey"
type="a"
:href="siteStore.docsBase + `/admin/permissions#full-access`"
target="_blank"
:aria-label="t(`common.actions.viewDocs`)">
<w-tooltip>{{ t(`common.actions.viewDocs`) }}</w-tooltip>
</w-btn>
</template>
</w-card-header>
<w-item tag="label"> <w-item tag="label">
<w-item-section class="items-center" style="flex: 0 0 40px"> <w-item-section class="items-center" style="flex: 0 0 40px">
<w-icon name="la:snowflake" color="negative" size="sm" /> <w-icon name="la:fire-alt" color="negative" size="sm" />
</w-item-section> </w-item-section>
<w-item-section> <w-item-section>
<w-item-label>{{ systemPermission.permission }}</w-item-label> <w-item-label>{{ systemPermission.permission }}</w-item-label>
@ -525,7 +616,10 @@
<w-toggle <w-toggle
v-model="state.group.permissions" v-model="state.group.permissions"
:val="systemPermission.permission" :val="systemPermission.permission"
:disable="isSystemPermissionLocked(systemPermission.permission)" :disable="
isSystemPermissionLocked(systemPermission.permission) ||
!canManagePermissions
"
:aria-label="systemPermission.permission" /> :aria-label="systemPermission.permission" />
</w-item-section> </w-item-section>
</w-item> </w-item>
@ -564,7 +658,10 @@
color="grey" color="grey"
type="a" type="a"
:href="siteStore.docsBase + `/admin/groups#users`" :href="siteStore.docsBase + `/admin/groups#users`"
target="_blank" /> target="_blank"
:aria-label="t(`common.actions.viewDocs`)">
<w-tooltip>{{ t(`common.actions.viewDocs`) }}</w-tooltip>
</w-btn>
<w-input <w-input
class="denser fill-outline mr-2" class="denser fill-outline mr-2"
outlined outlined
@ -794,63 +891,117 @@ const usersHeaders = [
] ]
/** /**
* The group-wide permissions, in the order the screen offers them. * The group-wide permissions, as cards, in the order the screen offers them.
* *
* Grouped by what they are about rather than alphabetically: getting into the admin area, then the * Grouped by what they are ABOUT rather than listed flat: what getting into the admin area buys on
* site-bound screens, then the people screens, then the two read-only views. `manage:system` is * its own, what runs a site, and what runs the people. A flat list of ten made a reader work out for
* deliberately NOT here — it is not one more entry on this list but the absence of the list, so it * themselves that `manage:theme` and `manage:groups` answer completely different questions.
* has a card of its own. See `systemPermission`. *
* `column` is which half of the screen a card sits in; `icon` is the mark every row in it wears, so
* that a permission is recognisable as belonging to its group at a glance rather than by reading the
* heading above it; `docs` is the fragment its help button links to, since each card answers to its
* own section of the documentation rather than to one page for the whole screen. `manage:system` is deliberately in
* neither list — it is not one more entry but the absence of the list, so it has a card of its own.
* See `systemPermission`.
*/ */
const permissions = [ const permissionCards = [
{
key: 'general',
docs: '/admin/permissions#global-permissions',
icon: 'la:snowflake',
column: 'left',
title: 'admin.groups.permissionsGeneral',
permissions: [
{ {
permission: 'access:admin', permission: 'access:admin',
hint: 'Can access the administration and view the dashboard. Cannot perform any other action unless other permissions are also granted.' hint: 'Can access the administration and view the dashboard. Cannot perform any other action unless other permissions are also granted.'
}, },
{
permission: 'read:audit',
hint: 'Can read the audit log, i.e. the record of what everybody on this wiki has done.'
},
{
permission: 'read:metrics',
hint: 'Can scrape the Prometheus metrics endpoint from an address it is not open to anonymously.'
}
]
},
{
key: 'site',
docs: '/admin/permissions#site-management-matrix',
icon: 'la:landmark',
column: 'left',
title: 'admin.groups.permissionsSite',
permissions: [
{ {
permission: 'manage:sites', permission: 'manage:sites',
hint: 'Can create / manage sites, and every setting bound to one: general, analytics, approvals, comments, content blocks, editors, locale, login, storage and theme.' hint: 'Can create / manage sites and their settings: general, analytics, approvals, comments, content blocks, editors, locale and login. Theme and storage are separate permissions.'
}, },
{ {
permission: 'manage:theme', permission: 'manage:theme',
hint: 'Can modify site theme settings, including the CSS, head and body injected into every page.' hint: 'Can modify site theme settings, including the CSS, head and body injected into every page. This is the only permission that can.'
}, },
{ {
permission: 'manage:navigation', permission: 'manage:storage',
hint: 'Can manage site navigation' hint: "Can modify site storage settings: which targets hold this site's content, where it is served from, and the actions that move it. This is the only permission that can."
}
]
}, },
{
key: 'webhooks',
docs: '/admin/permissions#site-management-matrix',
icon: 'la:bolt',
column: 'left',
title: 'admin.groups.permissionsWebhooks',
permissions: [
{
permission: 'read:webhooks',
hint: 'Can view webhooks and their settings, but not create or modify them. The authorization header of each one reads as a mask rather than as its value.'
},
{
permission: 'manage:webhooks',
hint: 'Can view, create, modify and delete webhooks.'
}
]
},
{
key: 'users',
docs: '/admin/permissions#user-management-matrix',
icon: 'la:user-tie',
column: 'right',
title: 'admin.groups.permissionsUsers',
permissions: [
{ {
permission: 'read:users', permission: 'read:users',
hint: 'Can view users, but not create or modify them.' hint: 'Can view users, but not create or modify them.'
}, },
{
permission: 'write:users',
hint: 'Can create new users, but not modify existing ones. A new user can only be placed in groups that do not administer the wiki.'
},
{ {
permission: 'manage:users', permission: 'manage:users',
hint: 'Can create / manage users (but not users with manage:system permissions)' hint: 'Can create and modify users, except those in a group with manage:system. Cannot move a user in or out of a group that administers the wiki.'
}, },
{ {
permission: 'read:groups', permission: 'read:groups',
hint: 'Can view groups and their permissions, but not create or modify them.' hint: 'Can view groups and their permissions, but not create or modify them.'
}, },
{ {
permission: 'manage:groups', permission: 'write:groups',
hint: 'Can create / manage groups and assign permissions (but not manage:system) / page rules' hint: 'Can create and manage groups and their page rules, but cannot change what a group is allowed to do, delete one, or change the membership of a group that administers the wiki.'
},
{
permission: 'read:audit',
hint: 'Can read the audit log, i.e. the record of what everybody on this wiki has done.'
}, },
{ {
permission: 'read:metrics', permission: 'manage:groups',
hint: 'Can scrape the Prometheus metrics endpoint from an address it is not open to anonymously.' hint: 'Can create / manage groups and assign permissions (but not manage:system) / page rules'
}
]
} }
] ]
/** const leftPermissionCards = permissionCards.filter((c) => c.column === 'left')
* The one permission that is not a permission to do something in particular. const rightPermissionCards = permissionCards.filter((c) => c.column === 'right')
*
* `manage:system` bypasses every check on the server rather than adding to what is granted, so a
* group holding it holds everything above whether or not any of it is ticked. Offered on a card of
* its own so that it cannot be read as the tenth item of a list of ten.
*/
const systemPermission = { const systemPermission = {
permission: 'manage:system', permission: 'manage:system',
hint: 'Can manage and access everything. Root administrator.' hint: 'Can manage and access everything. Root administrator.'
@ -950,6 +1101,14 @@ const rules = [
restrictedForSystem: false, restrictedForSystem: false,
disabled: false disabled: false
}, },
{
permission: 'manage:navigation',
title: 'Manage Navigation',
hint: 'Can change how pages here resolve their sidebar, and edit the menu itself where this rule also covers the page the menu belongs to.',
warning: false,
restrictedForSystem: true,
disabled: false
},
{ {
permission: 'read:assets', permission: 'read:assets',
title: 'View Assets', title: 'View Assets',
@ -1011,7 +1170,16 @@ const groupNameValidation = [(val) => /^[^<>"]+$/.test(val) || t('admin.groups.n
`manage:groups` / `write:groups` (see `api/groups.ts`), so the actions that perform one are hidden `manage:groups` / `write:groups` (see `api/groups.ts`), so the actions that perform one are hidden
rather than left to fail at the API. Exporting rules stays -- it only reads what is on screen. rather than left to fail at the API. Exporting rules stays -- it only reads what is on screen.
*/ */
const canManage = computed(() => userStore.can('manage:groups')) const canManage = computed(() => userStore.can('manage:groups') || userStore.can('write:groups'))
/*
Whether this user may rewrite what the group is ALLOWED to do, which is the Permissions tab and
nothing else. `write:groups` builds and arranges groups -- names, page rules, redirects, who is in
the ordinary ones -- but granting a group `manage:users` is a way of granting oneself anything, so
the global list stays with `manage:groups`. The tab is still shown, because reading what a group
may do is the point of being able to open it.
*/
const canManagePermissions = computed(() => userStore.can('manage:groups'))
/* /*
`manage:system` is the one permission a `manage:groups` holder may not move: granting it hands over `manage:system` is the one permission a `manage:groups` holder may not move: granting it hands over
@ -1255,6 +1423,18 @@ function newRule() {
}) })
} }
/*
Cycle a rule between ALLOW, DENY and FORCEALLOW. Guarded here rather than in the template, because
the control is an icon with a click handler rather than a form control there is a `disable` to set
-- a reader holding `read:groups` may look at a rule, not re-point it.
*/
function cycleRuleMode(rule) {
if (!canManage.value) {
return
}
rule.mode = getNextRuleMode(rule.mode)
}
function deleteRule(id) { function deleteRule(id) {
state.group.rules = state.group.rules.filter((r) => r.id !== id) state.group.rules = state.group.rules.filter((r) => r.id !== id)
} }

@ -2,7 +2,7 @@
<w-card style="min-width: 350px"> <w-card style="min-width: 350px">
<w-card-section class="card-header"> <w-card-section class="card-header">
<w-icon name="img:/_assets/icons/fluent-sidebar-menu.svg" left size="sm" /> <w-icon name="img:/_assets/icons/fluent-sidebar-menu.svg" left size="sm" />
<span>{{t(`navEdit.title`)}}</span> <span>{{ t(`navEdit.title`) }}</span>
</w-card-section> </w-card-section>
<w-list padding> <w-list padding>
<template v-if="isRoot"> <template v-if="isRoot">
@ -114,7 +114,6 @@ const props = defineProps({
} }
}) })
// STORES // STORES
const pageStore = usePageStore() const pageStore = usePageStore()
@ -139,6 +138,12 @@ const state = reactive({
* Null means nothing to inherit: the sidebar above this page is hidden. * Null means nothing to inherit: the sidebar above this page is hidden.
*/ */
inheritedNavId: null, inheritedNavId: null,
/*
Whether the server will accept ITEMS from this page, which is a question about the entry the menu
belongs to rather than about this one -- so it is answered by the server and not worked out here.
See `manage:navigation` in the permissions section of CLAUDE.md.
*/
canEditItems: false,
loading: 0 loading: 0
}) })
@ -149,9 +154,18 @@ const isRoot = computed(() => {
}) })
const canEditMenuItems = computed(() => { const canEditMenuItems = computed(() => {
// -> Inheriting edits the menu this page shows where it lives, which needs there to be one /*
Which menu the items belong to is the mode's answer, so which permission is needed follows it.
An overriding page owns the menu it writes, so holding `manage:navigation` HERE is the whole of it
-- and this menu is only rendered for somebody who does. Inheriting reaches up to an ancestor
instead, and changing that menu changes what every page under it shows, which is why the server
answers `canEditItems` separately: somebody who runs one section can re-point their own pages
without being able to rewrite the menu handed down to them.
*/
if (!isRoot.value && state.mode === 'inherit') { if (!isRoot.value && state.mode === 'inherit') {
return Boolean(state.inheritedNavId) // -> And there has to BE one: an ancestor that hides the sidebar leaves nothing to edit
return state.canEditItems && Boolean(state.inheritedNavId)
} }
return ['inherit', 'override', 'overrideExact'].includes(state.mode) return ['inherit', 'override', 'overrideExact'].includes(state.mode)
}) })
@ -183,6 +197,7 @@ async function loadInheritedNav() {
`sites/${siteStore.id}/navigation/pages/${pageStore.id}/inherited` `sites/${siteStore.id}/navigation/pages/${pageStore.id}/inherited`
).json() ).json()
state.inheritedNavId = resp?.navigationId ?? null state.inheritedNavId = resp?.navigationId ?? null
state.canEditItems = Boolean(resp?.canEditItems)
// -> A row appearing under the list makes the menu taller than the popup it was measured for // -> A row appearing under the list makes the menu taller than the popup it was measured for
nextTick(() => { nextTick(() => {
props.updatePositionHandler() props.updatePositionHandler()

@ -183,6 +183,7 @@ import { notify } from '@/composables/notify'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useAdminStore } from '@/stores/admin' import { useAdminStore } from '@/stores/admin'
import { useUserStore } from '@/stores/user'
// EMITS // EMITS
@ -197,6 +198,7 @@ const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogCom
// STORES // STORES
const adminStore = useAdminStore() const adminStore = useAdminStore()
const userStore = useUserStore()
// I18N // I18N
@ -291,7 +293,17 @@ async function loadGroups() {
state.loadingGroups = true state.loadingGroups = true
try { try {
const groups = await API_CLIENT.get('groups').json() const groups = await API_CLIENT.get('groups').json()
state.groups = (groups ?? []).filter((g) => g.id !== '10000000-0000-4000-8000-000000000001') /*
Guests are never a group an account is created into, and neither is a group that administers
the wiki: putting a NEW user in one grants whatever it can reach, which is the same act as
promoting an existing user and meets the same refusal at the endpoint. Only `manage:system`
may, so for everybody else the option is not offered rather than refused on submit.
*/
state.groups = (groups ?? []).filter(
(g) =>
g.id !== '10000000-0000-4000-8000-000000000001' &&
(userStore.can('manage:system') || !g.isElevated)
)
} catch (err) { } catch (err) {
notify({ notify({
type: 'negative', type: 'negative',

@ -95,6 +95,7 @@
<w-input <w-input
outlined outlined
v-model="state.user.name" v-model="state.user.name"
:disable="!canManage"
dense dense
:rules="[ :rules="[
(val) => invalidCharsRegex.test(val) || t('admin.users.nameInvalidChars') (val) => invalidCharsRegex.test(val) || t('admin.users.nameInvalidChars')
@ -114,6 +115,7 @@
<w-input <w-input
outlined outlined
v-model="state.user.email" v-model="state.user.email"
:disable="!canManage"
dense dense
:aria-label="t(`admin.users.email`)" /> :aria-label="t(`admin.users.email`)" />
</w-item-section> </w-item-section>
@ -130,6 +132,7 @@
<w-input <w-input
outlined outlined
v-model="state.user.meta.location" v-model="state.user.meta.location"
:disable="!canManage"
dense dense
:aria-label="t(`admin.users.location`)" /> :aria-label="t(`admin.users.location`)" />
</w-item-section> </w-item-section>
@ -145,6 +148,7 @@
<w-input <w-input
outlined outlined
v-model="state.user.meta.jobTitle" v-model="state.user.meta.jobTitle"
:disable="!canManage"
dense dense
:aria-label="t(`admin.users.jobTitle`)" /> :aria-label="t(`admin.users.jobTitle`)" />
</w-item-section> </w-item-section>
@ -160,6 +164,7 @@
<w-input <w-input
outlined outlined
v-model="state.user.meta.pronouns" v-model="state.user.meta.pronouns"
:disable="!canManage"
dense dense
:aria-label="t(`admin.users.pronouns`)" /> :aria-label="t(`admin.users.pronouns`)" />
</w-item-section> </w-item-section>
@ -178,6 +183,7 @@
<w-select <w-select
outlined outlined
v-model="state.user.prefs.timezone" v-model="state.user.prefs.timezone"
:disable="!canManage"
:options="timezones" :options="timezones"
option-value="value" option-value="value"
option-label="text" option-label="text"
@ -199,6 +205,7 @@
<w-select <w-select
outlined outlined
v-model="state.user.prefs.dateFormat" v-model="state.user.prefs.dateFormat"
:disable="!canManage"
emit-value emit-value
map-options map-options
dense dense
@ -223,6 +230,7 @@
<w-item-section class="flex-none"> <w-item-section class="flex-none">
<w-btn-toggle <w-btn-toggle
v-model="state.user.prefs.timeFormat" v-model="state.user.prefs.timeFormat"
:disable="!canManage"
push push
glossy glossy
no-caps no-caps
@ -243,6 +251,7 @@
<w-item-section class="flex-none"> <w-item-section class="flex-none">
<w-btn-toggle <w-btn-toggle
v-model="state.user.prefs.appearance" v-model="state.user.prefs.appearance"
:disable="!canManage"
push push
glossy glossy
no-caps no-caps
@ -264,6 +273,7 @@
<w-item-section class="flex-none"> <w-item-section class="flex-none">
<w-btn-toggle <w-btn-toggle
v-model="state.user.prefs.cvd" v-model="state.user.prefs.cvd"
:disable="!canManage"
push push
glossy glossy
no-caps no-caps
@ -327,6 +337,7 @@
<w-input <w-input
outlined outlined
v-model="state.user.meta.notes" v-model="state.user.meta.notes"
:disable="!canManage"
type="textarea" type="textarea"
:aria-label="t(`admin.users.notes`)" :aria-label="t(`admin.users.notes`)"
input-style="min-height: 243px" input-style="min-height: 243px"
@ -381,6 +392,7 @@
<w-item-section avatar> <w-item-section avatar>
<w-toggle <w-toggle
v-model="localAuth.mustChangePwd" v-model="localAuth.mustChangePwd"
:disable="!canManage"
color="primary" color="primary"
checked-icon="la:check" checked-icon="la:check"
unchecked-icon="la:times" unchecked-icon="la:times"
@ -397,6 +409,7 @@
<w-item-section avatar> <w-item-section avatar>
<w-toggle <w-toggle
v-model="localAuth.restrictLogin" v-model="localAuth.restrictLogin"
:disable="!canManage"
color="primary" color="primary"
checked-icon="la:check" checked-icon="la:check"
unchecked-icon="la:times" unchecked-icon="la:times"
@ -415,6 +428,7 @@
<w-item-section avatar> <w-item-section avatar>
<w-toggle <w-toggle
v-model="localAuth.isTfaRequired" v-model="localAuth.isTfaRequired"
:disable="!canManage"
color="primary" color="primary"
checked-icon="la:check" checked-icon="la:check"
unchecked-icon="la:times" unchecked-icon="la:times"
@ -485,12 +499,18 @@
><w-item-label>{{ grp.name }}</w-item-label></w-item-section ><w-item-label>{{ grp.name }}</w-item-label></w-item-section
> >
<w-item-section side> <w-item-section side>
<!--
Not offered for a group that administers the wiki: moving somebody in or out
of one is `manage:system`'s to do, and the endpoint refuses it. `isElevated`
comes with the group listing rather than being worked out here, since this
screen is never given a group's permissions.
-->
<w-btn <w-btn
class="acrylic-btn" class="acrylic-btn"
flat flat
icon="la:times" icon="la:times"
color="accent" color="accent"
v-if="canManage" v-if="canManage && mayChangeMembershipOf(grp.id)"
@click="unassignGroup(grp.id)" @click="unassignGroup(grp.id)"
:aria-label="t(`admin.users.unassignGroup`)"> :aria-label="t(`admin.users.unassignGroup`)">
<w-tooltip anchor="center left" self="center right">{{ <w-tooltip anchor="center left" self="center right">{{
@ -501,13 +521,17 @@
</w-item> </w-item>
</template> </template>
</w-card> </w-card>
<w-card class="shadow-1 py-2 mt-4"> <!--
The whole card, not just its button: it exists only to assign a group, so for a
reader holding `read:users` a picker with nothing to press is worse than no card.
-->
<w-card class="shadow-1 py-2 mt-4" v-if="canManage">
<w-item> <w-item>
<blueprint-icon icon="join" /> <blueprint-icon icon="join" />
<w-item-section> <w-item-section>
<w-select <w-select
outlined outlined
:options="state.groups" :options="assignableGroups"
v-model="state.groupToAdd" v-model="state.groupToAdd"
map-options map-options
emit-value emit-value
@ -554,6 +578,7 @@
<w-item-section> <w-item-section>
<util-code-editor <util-code-editor
v-model="metadata" v-model="metadata"
:readonly="!canManage"
language="json" language="json"
:min-height="500" :min-height="500"
aria-label="Metadata (JSON)" /> aria-label="Metadata (JSON)" />
@ -750,6 +775,25 @@ const timezones = Intl.supportedValuesOf('timeZone')
*/ */
const canManage = computed(() => userStore.can('manage:users')) const canManage = computed(() => userStore.can('manage:users'))
/*
Whether this user may move somebody in or out of a given group.
A group that administers the wiki (`isElevated` on the listing -- `write:users`, `manage:users`,
`write:groups`, `manage:groups`, `manage:system`) is `manage:system`'s alone to staff: its
membership IS the permission, so `manage:users` handing it out would be `manage:users` granting
itself anything. The endpoint refuses either direction; this keeps the control off the screen
rather than letting somebody press it and read a 403.
*/
function mayChangeMembershipOf(groupId) {
if (userStore.can('manage:system')) {
return true
}
return !state.groups.find((g) => g.id === groupId)?.isElevated
}
/** The groups this user may actually be put into, which is what the picker should offer. */
const assignableGroups = computed(() => state.groups.filter((g) => mayChangeMembershipOf(g.id)))
const metadata = computed({ const metadata = computed({
get() { get() {
return JSON.stringify(state.user.meta ?? {}, null, 2) return JSON.stringify(state.user.meta ?? {}, null, 2)

@ -19,13 +19,14 @@
class="util-code-editor-input" class="util-code-editor-input"
:value="modelValue" :value="modelValue"
:aria-label="ariaLabel" :aria-label="ariaLabel"
:readonly="readonly"
spellcheck="false" spellcheck="false"
autocapitalize="off" autocapitalize="off"
autocomplete="off" autocomplete="off"
autocorrect="off" autocorrect="off"
@input="onInput" @input="onInput"
@scroll="onScroll" @scroll="onScroll"
@keydown.tab.exact.prevent="onTab" /> @keydown.tab.exact="onTab" />
</div> </div>
</template> </template>
@ -78,6 +79,18 @@ const props = defineProps({
type: String, type: String,
default: null default: null
}, },
/**
* Show the code but refuse edits.
*
* `readonly` rather than `disabled`: the text stays selectable, copyable and at full contrast, and
* a screen reader still reads it out -- which is the whole point for somebody who may look at a
* setting but not change it. A disabled textarea dims its own content and drops out of the tab
* order, so the reader loses the thing they came for.
*/
readonly: {
type: Boolean,
default: false
},
/** /**
* Sharp corners, for a field that spans its container edge to edge. * Sharp corners, for a field that spans its container edge to edge.
* *
@ -235,8 +248,17 @@ function onScroll(ev) {
Tab indents by two, as the editor this replaces did. Tab indents by two, as the editor this replaces did.
Shift+Tab is deliberately NOT handled, so it still moves focus and a keyboard user is never trapped Shift+Tab is deliberately NOT handled, so it still moves focus and a keyboard user is never trapped
in the field. in the field.
`preventDefault` is called here rather than through the template's `.prevent` modifier, because a
readonly field must not swallow Tab -- there it is a key that moves focus on, and this handler has
to return before deciding. Guarding the emit matters on its own: `readonly` stops TYPING into a
textarea, not a keydown handler that writes the model itself.
*/ */
function onTab(ev) { function onTab(ev) {
if (props.readonly) {
return
}
ev.preventDefault()
const el = ev.target const el = ev.target
const { selectionStart: start, selectionEnd: end, value } = el const { selectionStart: start, selectionEnd: end, value } = el
emit('update:modelValue', `${value.slice(0, start)} ${value.slice(end)}`) emit('update:modelValue', `${value.slice(0, start)} ${value.slice(end)}`)

@ -42,6 +42,7 @@
<w-item-section> <w-item-section>
<w-input <w-input
v-model="state.hook.name" v-model="state.hook.name"
:disable="!canManage"
outlined outlined
dense dense
:rules="hookNameValidation" :rules="hookNameValidation"
@ -56,6 +57,7 @@
<w-item-section> <w-item-section>
<w-select <w-select
v-model="state.hook.events" v-model="state.hook.events"
:disable="!canManage"
outlined outlined
:options="events" :options="events"
multiple multiple
@ -103,6 +105,7 @@
<w-item-label caption>{{ t(`admin.webhooks.urlHint`) }}</w-item-label> <w-item-label caption>{{ t(`admin.webhooks.urlHint`) }}</w-item-label>
<w-input <w-input
v-model="state.hook.url" v-model="state.hook.url"
:disable="!canManage"
class="mt-2" class="mt-2"
outlined outlined
dense dense
@ -126,6 +129,7 @@
<w-item-section avatar> <w-item-section avatar>
<w-toggle <w-toggle
v-model="state.hook.includeMetadata" v-model="state.hook.includeMetadata"
:disable="!canManage"
:aria-label="t(`admin.webhooks.includeMetadata`)" :aria-label="t(`admin.webhooks.includeMetadata`)"
@click.stop /> @click.stop />
</w-item-section> </w-item-section>
@ -139,6 +143,7 @@
<w-item-section avatar> <w-item-section avatar>
<w-toggle <w-toggle
v-model="state.hook.includeContent" v-model="state.hook.includeContent"
:disable="!canManage"
:aria-label="t(`admin.webhooks.includeContent`)" :aria-label="t(`admin.webhooks.includeContent`)"
@click.stop /> @click.stop />
</w-item-section> </w-item-section>
@ -152,6 +157,7 @@
<w-item-section avatar> <w-item-section avatar>
<w-toggle <w-toggle
v-model="state.hook.acceptUntrusted" v-model="state.hook.acceptUntrusted"
:disable="!canManage"
:aria-label="t(`admin.webhooks.acceptUntrusted`)" :aria-label="t(`admin.webhooks.acceptUntrusted`)"
@click.stop /> @click.stop />
</w-item-section> </w-item-section>
@ -163,6 +169,7 @@
<w-item-label caption>{{ t(`admin.webhooks.authHeaderHint`) }}</w-item-label> <w-item-label caption>{{ t(`admin.webhooks.authHeaderHint`) }}</w-item-label>
<w-input <w-input
v-model="state.hook.authHeader" v-model="state.hook.authHeader"
:disable="!canManage"
class="mt-2" class="mt-2"
outlined outlined
dense dense
@ -179,16 +186,27 @@
color="grey" color="grey"
padding="xs md" padding="xs md"
@click="onDialogCancel" /> @click="onDialogCancel" />
<!--
`read:webhooks` opens this dialog to read a webhook's events and settings; saving needs
`manage:webhooks`, so the button is absent rather than left to fail at the API. The fields
below stay readable for the same reason the group and user editors keep theirs -- see the
`disabled` bindings on each.
-->
<w-btn <w-btn
v-if="props.hookId" v-if="props.hookId && canManage"
unelevated unelevated
:label="t(`common.actions.save`)" :label="t(`common.actions.save`)"
color="primary" color="primary"
padding="xs md" padding="xs md"
:loading="state.isLoading" :loading="state.isLoading"
@click="save" /> @click="save" />
<!--
An explicit condition rather than `v-else`: with `canManage` on the Save button above, a
bare else caught the read-only case too and offered a reader a Create button on a webhook
that already exists.
-->
<w-btn <w-btn
v-else v-if="!props.hookId && canManage"
unelevated unelevated
:label="t(`common.actions.create`)" :label="t(`common.actions.create`)"
color="primary" color="primary"
@ -208,6 +226,8 @@ import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog' import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useUserStore } from '@/stores/user'
import { apiErrorMessage } from '@/helpers/apiError' import { apiErrorMessage } from '@/helpers/apiError'
// PROPS // PROPS
@ -227,10 +247,20 @@ defineEmits([...dialogComponentEmits])
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent() const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
// STORES
const userStore = useUserStore()
// I18N // I18N
const { t } = useI18n() const { t } = useI18n()
/*
Whether this user may change the webhook, as opposed to only reading it. `read:webhooks` opens the
dialog; `manage:webhooks` is what the endpoints behind Save and Create ask for.
*/
const canManage = computed(() => userStore.can('manage:webhooks'))
// DATA // DATA
const state = reactive({ const state = reactive({

@ -775,12 +775,17 @@ There are two kinds, granted separately and checked in different places.
## Global permissions ## Global permissions
Held site-wide, bound to no path. \`access:admin\`, \`read:users\`, \`manage:users\`, \`read:groups\`, Held site-wide, bound to no path. \`access:admin\`, \`read:users\`, \`write:users\`,
\`manage:groups\`, \`read:audit\`, \`read:metrics\`, \`manage:navigation\`, \`manage:theme\`, \`manage:users\`, \`read:groups\`, \`write:groups\`, \`manage:groups\`, \`read:audit\`,
\`manage:sites\`, \`manage:system\`. That list is the whole of it. \`read:metrics\`, \`manage:theme\`, \`manage:storage\`, \`manage:sites\`, \`read:webhooks\`,
\`manage:webhooks\`, \`manage:system\`. That list is the whole of it.
\`manage:system\` bypasses every check everywhere. \`manage:system\` bypasses every check everywhere.
A site's settings are split across three permissions that do not overlap: \`manage:sites\` for its
general settings, \`manage:theme\` for its appearance and \`manage:storage\` for where its content
is kept. Changing all of them takes all three.
## Page rule permissions ## Page rule permissions
Bound to paths, and to locales and sites. A group grants them through **rules**: each rule names some Bound to paths, and to locales and sites. A group grants them through **rules**: each rule names some

@ -194,23 +194,10 @@
</w-item-section> </w-item-section>
<w-item-section>{{ t('admin.login.title') }}</w-item-section> <w-item-section>{{ t('admin.login.title') }}</w-item-section>
</w-item> </w-item>
<w-item
:to="`/_admin/` + adminStore.currentSiteId + `/navigation`"
active-class="bg-primary text-white"
disabled
v-if="
flagsStore.experimental &&
(userStore.can(`manage:sites`) || userStore.can(`manage:navigation`))
">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-tree-structure.svg" />
</w-item-section>
<w-item-section>{{ t('admin.navigation.title') }}</w-item-section>
</w-item>
<w-item <w-item
:to="`/_admin/` + adminStore.currentSiteId + `/storage`" :to="`/_admin/` + adminStore.currentSiteId + `/storage`"
active-class="bg-primary text-white" active-class="bg-primary text-white"
v-if="userStore.can(`manage:sites`)"> v-if="userStore.can(`manage:storage`)">
<w-item-section avatar> <w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-ssd.svg" /> <w-icon name="img:/_assets/icons/fluent-ssd.svg" />
</w-item-section> </w-item-section>
@ -227,7 +214,7 @@
<w-item <w-item
:to="`/_admin/` + adminStore.currentSiteId + `/theme`" :to="`/_admin/` + adminStore.currentSiteId + `/theme`"
active-class="bg-primary text-white" active-class="bg-primary text-white"
v-if="userStore.can(`manage:sites`) || userStore.can(`manage:theme`)"> v-if="userStore.can(`manage:theme`)">
<w-item-section avatar> <w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-paint-roller.svg" /> <w-icon name="img:/_assets/icons/fluent-paint-roller.svg" />
</w-item-section> </w-item-section>
@ -412,7 +399,11 @@
</w-item-section> </w-item-section>
<w-item-section>{{ t('admin.utilities.title') }}</w-item-section> <w-item-section>{{ t('admin.utilities.title') }}</w-item-section>
</w-item> </w-item>
<w-item to="/_admin/webhooks" active-class="bg-primary text-white"> </template>
<w-item
to="/_admin/webhooks"
active-class="bg-primary text-white"
v-if="webhooksAreVisible">
<w-item-section avatar> <w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-lightning-bolt.svg" /> <w-icon name="img:/_assets/icons/fluent-lightning-bolt.svg" />
</w-item-section> </w-item-section>
@ -424,14 +415,16 @@
:class="countBadgeClass(adminStore.info.webhooksTotal)" /> :class="countBadgeClass(adminStore.info.webhooksTotal)" />
</w-item-section> </w-item-section>
</w-item> </w-item>
<w-item to="/_admin/flags" active-class="bg-primary text-white"> <w-item
to="/_admin/flags"
active-class="bg-primary text-white"
v-if="userStore.can(`manage:system`)">
<w-item-section avatar> <w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-windsock.svg" /> <w-icon name="img:/_assets/icons/fluent-windsock.svg" />
</w-item-section> </w-item-section>
<w-item-section>{{ t('admin.flags.title') }}</w-item-section> <w-item-section>{{ t('admin.flags.title') }}</w-item-section>
</w-item> </w-item>
</template> </template>
</template>
</w-list> </w-list>
</w-scroll-area> </w-scroll-area>
</w-drawer> </w-drawer>
@ -570,11 +563,18 @@ const leftDrawerOpen = computed({
*/ */
const showSidebarBtn = computed(() => !isWideViewport.value && !narrowSidebarOpen.value) const showSidebarBtn = computed(() => !isWideViewport.value && !narrowSidebarOpen.value)
/*
The site section is shown for any permission that reaches one of the screens inside it: a site's
settings are split across `manage:sites`, `manage:theme` and `manage:storage`, which do not overlap.
`manage:navigation` is deliberately absent — it is a page rule now, granted per path, and the screen
it used to reach here has gone. Navigation is edited from the sidebar of the page it belongs to.
*/
const siteSectionShown = computed(() => { const siteSectionShown = computed(() => {
return ( return (
userStore.can('manage:sites') || userStore.can('manage:sites') ||
userStore.can('manage:navigation') || userStore.can('manage:theme') ||
userStore.can('manage:theme') userStore.can('manage:storage')
) )
}) })
/* /*
@ -583,10 +583,14 @@ const siteSectionShown = computed(() => {
to pages nothing links to. to pages nothing links to.
*/ */
const groupsAreVisible = computed(() => { const groupsAreVisible = computed(() => {
return userStore.can('read:groups') || userStore.can('manage:groups') return (
userStore.can('read:groups') || userStore.can('write:groups') || userStore.can('manage:groups')
)
}) })
const usersAreVisible = computed(() => { const usersAreVisible = computed(() => {
return userStore.can('read:users') || userStore.can('manage:users') return (
userStore.can('read:users') || userStore.can('write:users') || userStore.can('manage:users')
)
}) })
const usersSectionShown = computed(() => { const usersSectionShown = computed(() => {
return groupsAreVisible.value || usersAreVisible.value return groupsAreVisible.value || usersAreVisible.value
@ -594,8 +598,15 @@ const usersSectionShown = computed(() => {
const auditIsVisible = computed(() => { const auditIsVisible = computed(() => {
return userStore.can('read:audit') return userStore.can('read:audit')
}) })
/*
Webhooks are their own pair of permissions rather than part of `manage:system`, so the item is
outside that block and the section opens for it too.
*/
const webhooksAreVisible = computed(() => {
return userStore.can('read:webhooks') || userStore.can('manage:webhooks')
})
const systemSectionShown = computed(() => { const systemSectionShown = computed(() => {
return userStore.can('manage:system') || auditIsVisible.value return userStore.can('manage:system') || auditIsVisible.value || webhooksAreVisible.value
}) })
const overlayIsShown = computed(() => { const overlayIsShown = computed(() => {
return Boolean(adminStore.overlay) return Boolean(adminStore.overlay)
@ -657,7 +668,7 @@ watch(
router.push({ params: { siteid: newValue } }) router.push({ params: { siteid: newValue } })
} }
// -> Storage is configured per site, so the light belongs to whichever one is selected // -> Storage is configured per site, so the light belongs to whichever one is selected
if (newValue && userStore.can('manage:sites')) { if (newValue && userStore.can('manage:storage')) {
adminStore.fetchStorageStatus(newValue) adminStore.fetchStorageStatus(newValue)
} }
} }
@ -680,7 +691,7 @@ onMounted(async () => {
} }
adminStore.fetchInfo() adminStore.fetchInfo()
// -> Only for a role that can see the Storage item at all; anyone else would be asking for a 403 // -> Only for a role that can see the Storage item at all; anyone else would be asking for a 403
if (adminStore.currentSiteId && userStore.can('manage:sites')) { if (adminStore.currentSiteId && userStore.can('manage:storage')) {
adminStore.fetchStorageStatus(adminStore.currentSiteId) adminStore.fetchStorageStatus(adminStore.currentSiteId)
} }
}) })

@ -353,8 +353,21 @@ const showSidebarActions = computed(() => siteStore.locales.showMenu || canBrows
page. Same call as the page header's authoring actions, at the same breakpoint -- an editing control page. Same call as the page header's authoring actions, at the same breakpoint -- an editing control
that needs a pointer is not offered on a screen that has none. that needs a pointer is not offered on a screen that has none.
*/ */
/*
`manage:navigation` is a page rule, so this is what the rules grant AT THIS PATH -- read off
`pagePermissions` rather than through `userStore.can()`, which also answers for the group-wide list
and would say "may manage navigation somewhere". Somewhere is how a button ends up leading to a 403.
Holding it here buys the MODE at minimum; whether the menu's items are editable too depends on the
entry the menu belongs to, which only the server can resolve -- `canEditItems` on the inherited
response is what says so, and `NavEditMenu` reads it.
*/
const showEditNav = computed(() => { const showEditNav = computed(() => {
return userStore.authenticated && userStore.can('manage:navigation') && isAtLeastSm.value return (
userStore.authenticated &&
userStore.pagePermissions.includes('manage:navigation') &&
isAtLeastSm.value
)
}) })
// WATCHERS // WATCHERS

@ -5,7 +5,9 @@
<img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-people.svg" /> <img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-people.svg" />
</div> </div>
<div class="min-w-0 flex-1 pl-4"> <div class="min-w-0 flex-1 pl-4">
<div class="text-h5 admin-page-title animated fadeInLeft">{{ t('admin.groups.title') }}</div> <div class="text-h5 admin-page-title animated fadeInLeft">
{{ t('admin.groups.title') }}
</div>
<div class="text-subtitle1 text-grey animated fadeInLeft wait-p2s"> <div class="text-subtitle1 text-grey animated fadeInLeft wait-p2s">
{{ t('admin.groups.subtitle') }} {{ t('admin.groups.subtitle') }}
</div> </div>
@ -95,7 +97,7 @@
no-caps /> no-caps />
<w-btn <w-btn
class="acrylic-btn" class="acrylic-btn"
v-if="canManage" v-if="canDelete"
flat flat
icon="la:trash" icon="la:trash"
:color="props.row.isSystem ? `grey` : `negative`" :color="props.row.isSystem ? `grey` : `negative`"
@ -156,10 +158,15 @@ useMeta(() => ({
// COMPUTED // COMPUTED
/* /*
`read:groups` reaches this page too (see the nav in `AdminLayout`), and everything that writes needs `read:groups` reaches this page too (see the nav in `AdminLayout`), and writing needs one of the two
`manage:groups` -- so the controls behind it are hidden rather than left to fail at the API. group-editing rungs -- so the controls behind them are hidden rather than left to fail at the API.
`write:groups` creates and arranges groups; `manage:groups` additionally decides what a group is
ALLOWED to do and is the only one that may delete one. Hence two computeds rather than one: the
editor and the New button take either, the trash takes only the second.
*/ */
const canManage = computed(() => userStore.can('manage:groups')) const canManage = computed(() => userStore.can('manage:groups') || userStore.can('write:groups'))
const canDelete = computed(() => userStore.can('manage:groups'))
// DATA // DATA

@ -1,647 +0,0 @@
<template>
<w-page class="admin-navigation">
<div class="flex flex-wrap p-4 items-center">
<div class="flex-none">
<img
class="admin-icon animated fadeInLeft"
src="/_assets/icons/fluent-tree-structure.svg" />
</div>
<div class="min-w-0 flex-1 pl-4">
<div class="text-h5 admin-page-title animated fadeInLeft">
{{ t('admin.navigation.title') }}
</div>
<div class="text-subtitle1 text-grey animated fadeInLeft wait-p2s">
{{ t('admin.navigation.subtitle') }}
</div>
</div>
<div class="flex-none">
<w-btn
class="acrylic-btn mr-2"
icon="la:question-circle"
flat
color="grey"
:aria-label="t(`common.actions.viewDocs`)"
:href="siteStore.docsBase + `/admin/navigation`"
target="_blank">
<w-tooltip>{{ t(`common.actions.viewDocs`) }}</w-tooltip>
</w-btn>
<w-btn
class="mr-2 acrylic-btn"
icon="la:redo-alt"
flat
color="secondary"
:loading="state.loading > 0"
:aria-label="t(`common.actions.refresh`)"
@click="load">
<w-tooltip>{{ t(`common.actions.refresh`) }}</w-tooltip>
</w-btn>
<w-btn
unelevated
icon="mdi:check"
:label="t(`common.actions.apply`)"
color="secondary"
@click="save"
:disabled="state.loading > 0" />
</div>
</div>
<w-separator inset />
<div class="flex flex-wrap p-4 gap-4">
<div class="flex-none">
<w-card class="mt-2">{{ t('admin.navigation.mode') }}</w-card>
<w-card class="bg-dark mt-2">
<w-list style="min-width: 350px" padding dark>
<w-item>
<w-item-section>
<w-select
dark
outlined
option-value="value"
option-label="text"
emit-value
map-options
dense
options-dense
:label="t(`admin.navigation.mode`)"
:aria-label="t(`admin.navigation.mode`)" />
</w-item-section>
</w-item>
</w-list>
</w-card>
</div>
</div>
</w-page>
<!-- v-container.pa-0.mt-3(fluid, grid-list-lg) -->
<!-- v-row(dense) -->
<!-- v-col(cols='3') -->
<!-- v-card.animated.fadeInUp -->
<!-- v-toolbar(color='teal', dark, dense, flat, height='56') -->
<!-- v-toolbar-title.subtitle-1 {{$t('admin.navigation.mode')}} -->
<!-- v-list(nav, two-line) -->
<!-- v-list-item-group(v-model='config.mode', mandatory, :color='$vuetify.theme.dark ? `teal lighten-3` : `teal`') -->
<!-- v-list-item(value='TREE') -->
<!-- v-list-item-avatar -->
<!-- img(src='/_assets/svg/icon-tree-structure-dotted.svg', alt='Site Tree') -->
<!-- v-list-item-content -->
<!-- v-list-item-title {{$t('admin.navigation.modeSiteTree.title')}} -->
<!-- v-list-item-subtitle {{$t('admin.navigation.modeSiteTree.description')}} -->
<!-- v-list-item-avatar -->
<!-- v-icon(v-if='$vuetify.theme.dark', :color='config.mode === `TREE` ? `teal lighten-3` : `grey darken-2`') mdi-check-circle -->
<!-- v-icon(v-else, :color='config.mode === `TREE` ? `teal` : `grey lighten-3`') mdi-check-circle -->
<!-- v-list-item(value='STATIC') -->
<!-- v-list-item-avatar -->
<!-- img(src='/_assets/svg/icon-features-list.svg', alt='Static Navigation') -->
<!-- v-list-item-content -->
<!-- v-list-item-title {{$t('admin.navigation.modeStatic.title')}} -->
<!-- v-list-item-subtitle {{$t('admin.navigation.modeStatic.description')}} -->
<!-- v-list-item-avatar -->
<!-- v-icon(v-if='$vuetify.theme.dark', :color='config.mode === `STATIC` ? `teal lighten-3` : `grey darken-2`') mdi-check-circle -->
<!-- v-icon(v-else, :color='config.mode === `STATIC` ? `teal` : `grey lighten-3`') mdi-check-circle -->
<!-- v-list-item(value='MIXED') -->
<!-- v-list-item-avatar -->
<!-- img(src='/_assets/svg/icon-user-menu-male-dotted.svg', alt='Custom Navigation') -->
<!-- v-list-item-content -->
<!-- v-list-item-title {{$t('admin.navigation.modeCustom.title')}} -->
<!-- v-list-item-subtitle {{$t('admin.navigation.modeCustom.description')}} -->
<!-- v-list-item-avatar -->
<!-- v-icon(v-if='$vuetify.theme.dark', :color='config.mode === `MIXED` ? `teal lighten-3` : `grey darken-2`') mdi-check-circle -->
<!-- v-icon(v-else, :color='config.mode === `MIXED` ? `teal` : `grey lighten-3`') mdi-check-circle -->
<!-- v-list-item(value='NONE') -->
<!-- v-list-item-avatar -->
<!-- img(src='/_assets/svg/icon-cancel-dotted.svg', alt='None') -->
<!-- v-list-item-content -->
<!-- v-list-item-title {{$t('admin.navigation.modeNone.title')}} -->
<!-- v-list-item-subtitle {{$t('admin.navigation.modeNone.description')}} -->
<!-- v-list-item-avatar -->
<!-- v-icon(v-if='$vuetify.theme.dark', :color='config.mode === `none` ? `teal lighten-3` : `grey darken-2`') mdi-check-circle -->
<!-- v-icon(v-else, :color='config.mode === `none` ? `teal` : `grey lighten-3`') mdi-check-circle -->
<!-- v-col(cols='9', v-if='config.mode === `MIXED` || config.mode === `STATIC`') -->
<!-- v-card.animated.fadeInUp.wait-p2s -->
<!-- v-row(no-gutters, align='stretch') -->
<!-- v-col(style='flex: 0 0 350px;') -->
<!-- v-card.grey(flat, style='height: 100%; border-radius: 4px 0 0 4px;', :class='$vuetify.theme.dark ? `darken-4-l5` : `lighten-3`') -->
<!-- .teal.lighten-1.pa-2.d-flex(style='margin-bottom: 1px; height:56px;') -->
<!-- v-select( -->
<!-- :disabled='locales.length < 2' -->
<!-- label='Locale' -->
<!-- hide-details -->
<!-- solo -->
<!-- flat -->
<!-- background-color='teal darken-2' -->
<!-- dark -->
<!-- dense -->
<!-- v-model='currentLang' -->
<!-- :items='locales' -->
<!-- item-text='nativeName' -->
<!-- item-value='code' -->
<!-- ) -->
<!-- v-tooltip(top) -->
<!-- template(v-slot:activator='{ on }') -->
<!-- v-btn.ml-2(icon, tile, color='white', v-on='on', @click='copyFromLocaleDialogIsShown = true') -->
<!-- v-icon mdi-arrange-send-backward -->
<!-- span {{$t('admin.navigation.copyFromLocale')}} -->
<!-- v-list.py-2(dense, nav, dark, class='blue darken-2', style='border-radius: 0;') -->
<!-- v-list-item(v-if='currentTree.length < 1') -->
<!-- v-list-item-avatar(size='24'): v-icon(color='blue lighten-3') mdi-alert -->
<!-- v-list-item-content -->
<!-- em.caption.blue--text.text--lighten-4 {{$t('navigation.emptyList')}} -->
<!-- draggable(v-model='currentTree') -->
<!-- template(v-for='navItem in currentTree') -->
<!-- v-list-item( -->
<!-- v-if='navItem.kind === "link"' -->
<!-- :key='navItem.id' -->
<!-- :class='(navItem === current) ? "blue" : ""' -->
<!-- @click='selectItem(navItem)' -->
<!-- ) -->
<!-- v-list-item-avatar(size='24', tile) -->
<!-- v-icon(v-if='navItem.icon.match(/fa[a-z] fa-/)', size='19') {{ navItem.icon }} -->
<!-- v-icon(v-else) {{ navItem.icon }} -->
<!-- v-list-item-title {{navItem.label}} -->
<!-- .py-2.clickable( -->
<!-- v-else-if='navItem.kind === "divider"' -->
<!-- :key='navItem.id' -->
<!-- :class='(navItem === current) ? "blue" : ""' -->
<!-- @click='selectItem(navItem)' -->
<!-- ) -->
<!-- v-divider -->
<!-- v-subheader.pl-4.clickable( -->
<!-- v-else-if='navItem.kind === "header"' -->
<!-- :key='navItem.id' -->
<!-- :class='(navItem === current) ? "blue" : ""' -->
<!-- @click='selectItem(navItem)' -->
<!-- ) {{navItem.label}} -->
<!-- v-card-chin -->
<!-- v-menu(offset-y, bottom, min-width='200px', style='flex: 1 1;') -->
<!-- template(v-slot:activator='{ on }') -->
<!-- v-btn(v-on='on', color='primary', depressed, block) -->
<!-- v-icon(left) mdi-plus -->
<!-- span {{$t('common.actions.add')}} -->
<!-- v-list -->
<!-- v-list-item(@click='addItem("link")') -->
<!-- v-list-item-avatar(size='24'): v-icon mdi-link -->
<!-- v-list-item-title {{$t('navigation.link')}} -->
<!-- v-list-item(@click='addItem("header")') -->
<!-- v-list-item-avatar(size='24'): v-icon mdi-format-title -->
<!-- v-list-item-title {{$t('navigation.header')}} -->
<!-- v-list-item(@click='addItem("divider")') -->
<!-- v-list-item-avatar(size='24'): v-icon mdi-minus -->
<!-- v-list-item-title {{$t('navigation.divider')}} -->
<!-- v-col -->
<!-- v-card(flat, style='border-radius: 0 4px 4px 0;') -->
<!-- template(v-if='current.kind === "link"') -->
<!-- v-toolbar(height='56', color='teal lighten-1', flat, dark) -->
<!-- .subtitle-1 {{$t('navigation.edit', { kind: $t('navigation.link') })}} -->
<!-- v-spacer -->
<!-- v-btn.px-5(color='white', outlined, @click='deleteItem(current)') -->
<!-- v-icon(left) mdi-delete -->
<!-- span {{$t('navigation.delete', { kind: $t('navigation.link') })}} -->
<!-- v-card-text -->
<!-- v-text-field( -->
<!-- outlined -->
<!-- :label='$t("navigation.label")' -->
<!-- prepend-icon='mdi:format-title' -->
<!-- v-model='current.label' -->
<!-- counter='255' -->
<!-- ) -->
<!-- v-text-field( -->
<!-- outlined -->
<!-- :label='$t("navigation.icon")' -->
<!-- prepend-icon='mdi:dice-5' -->
<!-- v-model='current.icon' -->
<!-- hide-details -->
<!-- ) -->
<!-- .caption.pt-3.pl-5 The default icon set is #[strong Material Design Icons]. In order to use another icon set, you must first select it in the Theme administration section. -->
<!-- .caption.pt-3.pl-5: strong Material Design Icons -->
<!-- .caption.pl-5 Refer to the #[a(href='https://materialdesignicons.com/', target='_blank') Material Design Icons Reference] for the list of all possible values. You must prefix all values with #[code mdi-], e.g. #[code mdi-home] -->
<!-- .caption.pt-3.pl-5: strong Font Awesome 5 -->
<!-- .caption.pl-5 Refer to the #[a(href='https://fontawesome.com/icons?d=gallery&m=free', target='_blank') Font Awesome 5 Reference] for the list of all possible values. You must prefix all values with #[code fas fa-], e.g. #[code fas fa-home]. Note that some icons use different prefixes (e.g. #[code fab], #[code fad], #[code fal], #[code far]). -->
<!-- .caption.pt-3.pl-5: strong Font Awesome 4 -->
<!-- .caption.pl-5 Refer to the #[a(href='https://fontawesome.com/v4.7.0/icons/', target='_blank') Font Awesome 4 Reference] for the list of all possible values. You must prefix all values with #[code fa fa-], e.g. #[code fa fa-home] -->
<!-- v-divider -->
<!-- v-card-text -->
<!-- v-select( -->
<!-- outlined -->
<!-- :label='$t("navigation.targetType")' -->
<!-- prepend-icon='mdi:near-me' -->
<!-- :items='navTypes' -->
<!-- v-model='current.targetType' -->
<!-- hide-details -->
<!-- ) -->
<!-- v-text-field.mt-4( -->
<!-- v-if='current.targetType === `external` || current.targetType === `externalblank`' -->
<!-- outlined -->
<!-- :label='$t("navigation.target")' -->
<!-- prepend-icon='mdi:near-me' -->
<!-- v-model='current.target' -->
<!-- hide-details -->
<!-- ) -->
<!-- .d-flex.align-center.mt-4(v-else-if='current.targetType === "page"') -->
<!-- v-btn.ml-8( -->
<!-- color='primary' -->
<!-- dark -->
<!-- @click='selectPage' -->
<!-- ) -->
<!-- v-icon(left) mdi-magnify -->
<!-- span {{$t('admin.navigation.selectPageButton')}} -->
<!-- .caption.ml-4.primary--text {{current.target}} -->
<!-- v-text-field( -->
<!-- v-else-if='current.targetType === `search`' -->
<!-- outlined -->
<!-- :label='$t("navigation.navType.searchQuery")' -->
<!-- prepend-icon='search' -->
<!-- v-model='current.target' -->
<!-- ) -->
<!-- v-divider -->
<!-- template(v-else-if='current.kind === "header"') -->
<!-- v-toolbar(height='56', color='teal lighten-1', flat, dark) -->
<!-- .subtitle-1 {{$t('navigation.edit', { kind: $t('navigation.header') })}} -->
<!-- v-spacer -->
<!-- v-btn.px-5(color='white', outlined, @click='deleteItem(current)') -->
<!-- v-icon(left) mdi-delete -->
<!-- span {{$t('navigation.delete', { kind: $t('navigation.header') })}} -->
<!-- v-card-text -->
<!-- v-text-field( -->
<!-- outlined -->
<!-- :label='$t("navigation.label")' -->
<!-- prepend-icon='mdi:format-title' -->
<!-- v-model='current.label' -->
<!-- ) -->
<!-- v-divider -->
<!-- div(v-else-if='current.kind === "divider"') -->
<!-- v-toolbar(height='56', color='teal lighten-1', flat, dark) -->
<!-- .subtitle-1 {{$t('navigation.edit', { kind: $t('navigation.divider') })}} -->
<!-- v-spacer -->
<!-- v-btn.px-5(color='white', outlined, @click='deleteItem(current)') -->
<!-- v-icon(left) mdi-delete -->
<!-- span {{$t('navigation.delete', { kind: $t('navigation.divider') })}} -->
<!-- v-card-text(v-if='current.kind') -->
<!-- v-radio-group.pl-8(v-model='current.visibilityMode', mandatory, hide-details) -->
<!-- v-radio(:label='$t("admin.navigation.visibilityMode.all")', value='all', color='primary') -->
<!-- v-radio.mt-3(:label='$t("admin.navigation.visibilityMode.restricted")', value='restricted', color='primary') -->
<!-- .pl-8 -->
<!-- v-select.pl-8.mt-3( -->
<!-- item-text='name' -->
<!-- item-value='id' -->
<!-- outlined -->
<!-- prepend-icon='mdi:account-group' -->
<!-- label='Groups' -->
<!-- :disabled='current.visibilityMode !== `restricted`' -->
<!-- v-model='current.visibilityGroups' -->
<!-- :items='groups' -->
<!-- persistent-hint -->
<!-- clearable -->
<!-- multiple -->
<!-- ) -->
<!-- template(v-else) -->
<!-- v-toolbar(height='56', color='teal lighten-1', flat, dark) -->
<!-- v-card-text.grey--text(v-if='currentTree.length > 0') {{$t('navigation.noSelectionText')}} -->
<!-- v-card-text.grey--text(v-else) {{$t('navigation.noItemsText')}} -->
<!-- v-dialog(v-model='copyFromLocaleDialogIsShown', max-width='650', persistent) -->
<!-- v-card -->
<!-- .dialog-header.is-short.is-teal -->
<!-- v-icon.mr-3(color='white') mdi-arrange-send-backward -->
<!-- span {{$t('admin.navigation.copyFromLocale')}} -->
<!-- v-card-text.pt-5 -->
<!-- .body-2 {{$t('admin.navigation.copyFromLocaleInfoText')}} -->
<!-- v-select.mt-3( -->
<!-- :items='locales' -->
<!-- item-text='nativeName' -->
<!-- item-value='code' -->
<!-- outlined -->
<!-- prepend-icon='mdi:web' -->
<!-- v-model='copyFromLocaleCode' -->
<!-- :label='$t(`admin.navigation.sourceLocale`)' -->
<!-- :hint='$t(`admin.navigation.sourceLocaleHint`)' -->
<!-- persistent-hint -->
<!-- ) -->
<!-- v-card-chin -->
<!-- v-spacer -->
<!-- v-btn(text, @click='copyFromLocaleDialogIsShown = false') {{$t('common.actions.cancel')}} -->
<!-- v-btn.px-3(depressed, color='primary', @click='copyFromLocale') -->
<!-- v-icon(left) mdi-chevron-right -->
<!-- span {{$t('common.actions.copy')}} -->
<!-- page-selector(mode='select', v-model='selectPageModal', :open-handler='selectPageHandle', path='home', :locale='currentLang') -->
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { computed, onMounted, reactive, watch, nextTick } from 'vue'
import { useMeta } from '@/composables/meta'
import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site'
import { intersectionBy, pull, unionBy } from 'es-toolkit/array'
import { v4 as uuid } from 'uuid'
import draggable from 'vuedraggable'
// STORES
const adminStore = useAdminStore()
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// META
useMeta(() => ({
title: t('admin.navigation.title')
}))
// DATA
const siteConfig = { lang: 'en' }
const siteLangs = [{ code: 'en' }]
const state = reactive({
loading: 0,
selectPageModal: false,
trees: [],
current: {},
currentLang: siteConfig.lang,
groups: [],
copyFromLocaleDialogIsShown: false,
config: {
mode: 'NONE'
},
allLocales: [],
copyFromLocaleCode: 'en'
})
// COMPUTED
const navTypes = computed(() => [
{ text: t('navigation.navType.external'), value: 'external' },
{ text: t('navigation.navType.externalblank'), value: 'externalblank' },
{ text: t('navigation.navType.home'), value: 'home' },
{ text: t('navigation.navType.page'), value: 'page' }
// { text: t('navigation.navType.searchQuery'), value: 'search' }
])
const locales = computed(() => {
// -> `(l) => l.code` rather than the `'code'` shorthand lodash took: es-toolkit's `*By` helpers
// want a mapper function, and a string reaches `uniqBy` as one and throws
return intersectionBy(
state.allLocales,
unionBy(siteLangs, [{ code: 'en' }, { code: siteConfig.lang }], (l) => l.code),
(l) => l.code
)
})
const currentTree = computed({
get() {
return state.trees.find((tree) => tree.locale === state.currentLang)?.items || []
},
set(val) {
const tree = state.trees.find((t) => t.locale === state.currentLang)
if (tree) {
tree.items = val
} else {
state.trees = [
...state.trees,
{
locale: state.currentLang,
items: val
}
]
}
}
})
// WATCHERS
watch(
() => state.currentLang,
(newValue, oldValue) => {
nextTick(() => {
if (state.currentTree.length > 0) {
state.current = state.currentTree[0]
} else {
state.current = {}
}
})
}
)
// METHODS
async function load() {}
function addItem(kind) {
let newItem = {
id: uuid(),
kind,
visibilityMode: 'all',
visibilityGroups: []
}
switch (kind) {
case 'link':
newItem = {
...newItem,
label: t('navigation.untitled', { kind: t('navigation.link') }),
icon: 'mdi:chevron-right',
targetType: 'home',
target: ''
}
break
case 'header':
newItem.label = t('navigation.untitled', { kind: t('navigation.header') })
break
}
state.currentTree = [...state.currentTree, newItem]
state.current = newItem
}
function deleteItem(item) {
state.currentTree = pull(state.currentTree, [item])
state.current = {}
}
function selectItem(item) {
state.current = item
}
function selectPage() {
state.selectPageModal = true
}
function selectPageHandle({ path, locale }) {
state.current.target = `/${locale}/${path}`
}
function copyFromLocale() {
state.copyFromLocaleDialogIsShown = false
state.currentTree = [
...state.currentTree,
...(state.trees.find((tree) => tree.locale === state.copyFromLocaleCode)?.items || [])
]
}
async function save() {
this.$store.commit('loadingStart', 'admin-navigation-save')
try {
/*
FIXME: This whole handler is dead. `APOLLO_CLIENT` is not defined anywhere -- the GraphQL client
went with the rest of Apollo -- so saving the navigation throws here, and the nine
`this.$store.commit(...)` calls around it throw too, this being `<script setup>` with no Vuex
store in the app at all. Porting it to `API_CLIENT` and a REST route is what fixes it; see
"GraphQL is being removed" in CLAUDE.md.
The disable keeps `no-undef` usable repo-wide rather than hiding this: the rule is what found
it, and the comment is here so it stays found.
*/
// eslint-disable-next-line no-undef
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation ($tree: [NavigationTreeInput]!, $mode: NavigationMode!) {
navigation{
updateTree(tree: $tree) {
responseResult {
succeeded
errorCode
slug
message
}
},
updateConfig(mode: $mode) {
responseResult {
succeeded
errorCode
slug
message
}
}
}
}
`,
variables: {
tree: state.trees,
mode: state.config.mode
}
})
if (
resp?.data.navigation.updateTree.responseResult.succeeded &&
resp?.data.navigation.updateConfig.responseResult.succeeded
) {
this.$store.commit('showNotification', {
message: t('navigation.saveSuccess'),
style: 'success',
icon: 'check'
})
} else {
throw new Error(
resp?.data.navigation.updateTree.operation.message || 'An unexpected error occurred.'
)
}
} catch (err) {
this.$store.commit('pushGraphError', err)
}
this.$store.commit('loadingStop', 'admin-navigation-save')
}
async function refresh() {
load()
state.current = {}
this.$store.commit('showNotification', {
message: 'Navigation has been refreshed.',
style: 'success',
icon: 'cached'
})
}
// apollo: {
// config: {
// query: `
// {
// navigation {
// config {
// mode
// }
// }
// }
// `,
// fetchPolicy: 'network-only',
// update: (data) => _.cloneDeep(data.navigation.config),
// watchLoading (isLoading) {
// this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-navigation-config')
// }
// },
// trees: {
// query: `
// {
// navigation {
// tree {
// locale
// items {
// id
// kind
// label
// icon
// targetType
// target
// visibilityMode
// visibilityGroups
// }
// }
// }
// }
// `,
// fetchPolicy: 'network-only',
// update: (data) => _.cloneDeep(data.navigation.tree),
// watchLoading (isLoading) {
// this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-navigation-tree')
// }
// },
// groups: {
// query: `
// query {
// groups {
// list {
// id
// name
// isSystem
// userCount
// createdAt
// updatedAt
// }
// }
// }
// `,
// fetchPolicy: 'network-only',
// update: (data) => data.groups.list,
// watchLoading (isLoading) {
// this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-navigation-groups')
// }
// },
// allLocales: {
// query: `
// {
// localization {
// locales {
// code
// name
// nativeName
// }
// }
// }
// `,
// fetchPolicy: 'network-only',
// update: (data) => data.localization.locales,
// watchLoading (isLoading) {
// this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-navigation-locales')
// }
// }
// }
</script>
<style lang="scss" scoped>
.clickable {
cursor: pointer;
&:hover {
background-color: rgba($blue-5, 0.25);
}
}
</style>

@ -736,7 +736,8 @@ async function save() {
/* /*
The theme's own endpoint rather than the general site update, because it is its own permission: The theme's own endpoint rather than the general site update, because it is its own permission:
`manage:theme` grants the look of a site without granting its hostname, locales or `manage:theme` grants the look of a site without granting its hostname, locales or
authentication, and the general update asks for `manage:sites`. authentication, and the general update asks for `manage:sites` -- which this route deliberately
does NOT accept, since the three site permissions do not overlap.
*/ */
const resp = await API_CLIENT.put(`sites/${adminStore.currentSiteId}/theme`, { const resp = await API_CLIENT.put(`sites/${adminStore.currentSiteId}/theme`, {
json: patchTheme json: patchTheme

@ -12,6 +12,7 @@
</div> </div>
<div class="flex-none flex items-center"> <div class="flex-none flex items-center">
<w-input <w-input
v-if="canList"
class="denser mr-2" class="denser mr-2"
outlined outlined
v-model="state.search" v-model="state.search"
@ -54,7 +55,7 @@
<user-defaults-menu /> <user-defaults-menu />
</w-btn> </w-btn>
<w-btn <w-btn
v-if="canManage" v-if="canCreate"
unelevated unelevated
icon="la:plus" icon="la:plus"
:label="t(`admin.users.create`)" :label="t(`admin.users.create`)"
@ -66,7 +67,11 @@
<w-separator inset /> <w-separator inset />
<div class="grid grid-cols-12 p-4 gap-4"> <div class="grid grid-cols-12 p-4 gap-4">
<div class="col-span-12"> <div class="col-span-12">
<w-card> <!--
Left out rather than shown empty for somebody holding `write:users` alone: a table of no
users is a statement that there are none, which is not what is being said.
-->
<w-card v-if="canList">
<w-table <w-table
:rows="state.users" :rows="state.users"
:columns="headers" :columns="headers"
@ -142,7 +147,7 @@
</template> </template>
</w-table> </w-table>
</w-card> </w-card>
<div class="flex items-center justify-center mt-6" v-if="state.totalPages > 1"> <div class="flex items-center justify-center mt-6" v-if="canList && state.totalPages > 1">
<w-pagination <w-pagination
v-model="state.currentPage" v-model="state.currentPage"
:max="state.totalPages" :max="state.totalPages"
@ -205,11 +210,22 @@ useMeta(() => ({
// COMPUTED // COMPUTED
/* /*
`read:users` reaches this page too (see the nav in `AdminLayout`), and everything that writes needs `read:users` reaches this page too (see the nav in `AdminLayout`), and everything that CHANGES an
`manage:users` -- so the controls behind it are hidden rather than left to fail at the API. existing user needs `manage:users` -- so the controls behind it are hidden rather than left to fail
at the API.
*/ */
const canManage = computed(() => userStore.can('manage:users')) const canManage = computed(() => userStore.can('manage:users'))
/*
Creating one is a rung of its own: `write:users` brings an account into existence without being
trusted with the accounts that already exist, so the New User button answers to either permission
while every row control above stays on `manage:users`.
*/
const canCreate = computed(() => canManage.value || userStore.can('write:users'))
/** Whether this user may be shown the accounts that already exist. */
const canList = computed(() => canManage.value || userStore.can('read:users'))
// DATA // DATA
const state = reactive({ const state = reactive({
@ -289,6 +305,14 @@ watch(
// METHODS // METHODS
async function load({ page } = {}) { async function load({ page } = {}) {
/*
`write:users` reaches this page to use the Create button and nothing else -- seeing the accounts
that already exist is `read:users`. Asking anyway would answer 403 and put a red toast over a
page that is working exactly as intended, so the listing is simply not requested.
*/
if (!canList.value) {
return
}
state.loading++ state.loading++
loading.show() loading.show()
try { try {

@ -7,7 +7,9 @@
src="/_assets/icons/fluent-lightning-bolt-animated.svg" /> src="/_assets/icons/fluent-lightning-bolt-animated.svg" />
</div> </div>
<div class="min-w-0 flex-1 pl-4"> <div class="min-w-0 flex-1 pl-4">
<div class="text-h5 admin-page-title animated fadeInLeft">{{ t('admin.webhooks.title') }}</div> <div class="text-h5 admin-page-title animated fadeInLeft">
{{ t('admin.webhooks.title') }}
</div>
<div class="text-subtitle1 text-grey animated fadeInLeft wait-p2s"> <div class="text-subtitle1 text-grey animated fadeInLeft wait-p2s">
{{ t('admin.webhooks.subtitle') }} {{ t('admin.webhooks.subtitle') }}
</div> </div>
@ -34,6 +36,7 @@
<w-tooltip>{{ t(`common.actions.refresh`) }}</w-tooltip> <w-tooltip>{{ t(`common.actions.refresh`) }}</w-tooltip>
</w-btn> </w-btn>
<w-btn <w-btn
v-if="canManage"
unelevated unelevated
icon="la:plus" icon="la:plus"
:label="t(`admin.webhooks.new`)" :label="t(`admin.webhooks.new`)"
@ -83,11 +86,7 @@
}}</w-tooltip> }}</w-tooltip>
</template> </template>
<template v-else-if="hook.state === `error`"> <template v-else-if="hook.state === `error`">
<w-icon <w-icon class="mr-2" color="negative" size="xs" name="la:exclamation-triangle" />
class="mr-2"
color="negative"
size="xs"
name="la:exclamation-triangle" />
<div class="text-caption text-negative">{{ t('admin.webhooks.stateError') }}</div> <div class="text-caption text-negative">{{ t('admin.webhooks.stateError') }}</div>
<w-tooltip anchor="center left" self="center right">{{ <w-tooltip anchor="center left" self="center right">{{
t('admin.webhooks.stateErrorHint') t('admin.webhooks.stateErrorHint')
@ -96,15 +95,22 @@
</w-item-section> </w-item-section>
<w-separator class="ml-4" vertical /> <w-separator class="ml-4" vertical />
<w-item-section side style="flex-direction: row; align-items: center"> <w-item-section side style="flex-direction: row; align-items: center">
<!--
`read:webhooks` opens this screen to see what the wiki is wired to; changing any of
it is `manage:webhooks`. The edit button becomes a view button rather than
disappearing, as the group and user lists do for their own read-only rungs -- the
dialog is where a webhook's events and settings are actually legible.
-->
<w-btn <w-btn
class="acrylic-btn mr-2" class="acrylic-btn mr-2"
color="indigo" color="indigo"
icon="la:pen" :icon="canManage ? `la:pen` : `la:eye`"
label="Edit" :label="canManage ? t(`common.actions.edit`) : t(`common.actions.view`)"
flat flat
no-caps no-caps
@click="editHook(hook.id)" /> @click="editHook(hook.id)" />
<w-btn <w-btn
v-if="canManage"
class="acrylic-btn" class="acrylic-btn"
color="red" color="red"
icon="la:trash" icon="la:trash"
@ -121,7 +127,7 @@
<script setup> <script setup>
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { onMounted, reactive } from 'vue' import { computed, onMounted, reactive } from 'vue'
import { useDark } from '@/composables/dark' import { useDark } from '@/composables/dark'
import { useMeta } from '@/composables/meta' import { useMeta } from '@/composables/meta'
@ -130,6 +136,7 @@ import { loading } from '@/composables/loading'
import { dialog } from '@/composables/dialog' import { dialog } from '@/composables/dialog'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
import WebhookEditDialog from '@/components/WebhookEditDialog.vue' import WebhookEditDialog from '@/components/WebhookEditDialog.vue'
import WebhookDeleteDialog from '@/components/WebhookDeleteDialog.vue' import WebhookDeleteDialog from '@/components/WebhookDeleteDialog.vue'
@ -141,6 +148,15 @@ const dark = useDark()
// STORES // STORES
const siteStore = useSiteStore() const siteStore = useSiteStore()
const userStore = useUserStore()
// COMPUTED
/*
`read:webhooks` reaches this page to read it; everything that writes needs `manage:webhooks`, so
those controls are hidden rather than left to fail at the API.
*/
const canManage = computed(() => userStore.can('manage:webhooks'))
// I18N // I18N

@ -101,7 +101,6 @@ const routes = [
{ path: ':siteid/editors', component: () => import('@/pages/AdminEditors.vue') }, { path: ':siteid/editors', component: () => import('@/pages/AdminEditors.vue') },
{ path: ':siteid/locale', component: () => import('@/pages/AdminLocale.vue') }, { path: ':siteid/locale', component: () => import('@/pages/AdminLocale.vue') },
{ path: ':siteid/login', component: () => import('@/pages/AdminLogin.vue') }, { path: ':siteid/login', component: () => import('@/pages/AdminLogin.vue') },
{ path: ':siteid/navigation', component: () => import('@/pages/AdminNavigation.vue') },
{ path: ':siteid/storage/:id?', component: () => import('@/pages/AdminStorage.vue') }, { path: ':siteid/storage/:id?', component: () => import('@/pages/AdminStorage.vue') },
{ path: ':siteid/theme', component: () => import('@/pages/AdminTheme.vue') }, { path: ':siteid/theme', component: () => import('@/pages/AdminTheme.vue') },
// -> Users // -> Users

Loading…
Cancel
Save