From 81fc8db4f7dd7f85731a0158f78d092a0d08a33d Mon Sep 17 00:00:00 2001 From: NGPixel Date: Sun, 9 Aug 2026 02:03:13 -0400 Subject: [PATCH] feat: improve admin dashboard + group permissions fixes --- backend/api/groups.ts | 66 ++++- backend/api/sites.ts | 2 +- backend/api/system.ts | 114 +++++---- backend/api/users.ts | 57 +++++ backend/locales/en.json | 6 +- backend/models/groups.ts | 41 +++ backend/models/jobs.ts | 14 ++ backend/models/users.ts | 10 +- frontend/public/_assets/icons/fluent-done.svg | 1 + .../public/_assets/icons/fluent-double-up.svg | 1 + .../public/_assets/icons/fluent-refresh.svg | 1 + frontend/src/assets/icons.generated.js | 5 +- frontend/src/components/GroupEditOverlay.vue | 55 +++- .../src/components/UserChangePwdDialog.vue | 4 +- frontend/src/components/UserEditOverlay.vue | 21 +- frontend/src/layouts/AdminLayout.vue | 91 ++++++- frontend/src/pages/AdminDashboard.vue | 235 ++++++++++++++++-- frontend/src/pages/AdminGroups.vue | 18 +- frontend/src/pages/AdminUsers.vue | 18 +- frontend/src/stores/admin.js | 19 +- 20 files changed, 677 insertions(+), 102 deletions(-) create mode 100644 frontend/public/_assets/icons/fluent-done.svg create mode 100644 frontend/public/_assets/icons/fluent-double-up.svg create mode 100644 frontend/public/_assets/icons/fluent-refresh.svg diff --git a/backend/api/groups.ts b/backend/api/groups.ts index 5dc612364..c74226112 100644 --- a/backend/api/groups.ts +++ b/backend/api/groups.ts @@ -1,6 +1,35 @@ import { CustomError } from '../helpers/common.ts' -import type { FastifyInstance } from 'fastify' -import type { GroupPatch, GroupRule } from '../models/groups.ts' +import { SYSTEM_PERMISSION } from '../models/groups.ts' +import type { FastifyInstance, FastifyRequest } from 'fastify' +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`. + * + * Membership of such a group IS the permission: adding somebody hands them the root of the instance, + * and removing somebody takes it away from a real administrator. Deleting the group does both at + * once, so it asks the same question. + * + * @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 + */ +function systemGroupGuard( + req: FastifyRequest, + group: GroupWithUserCount, + action = 'change who belongs to the group' +): CustomError | null { + if (!group.permissions.includes(SYSTEM_PERMISSION)) { + return null + } + if (WIKI.models.groups.holdsSystemPermission(req)) { + return null + } + return new CustomError( + 'groupMembershipSystemProtected', + `This group has the ${SYSTEM_PERMISSION} permission. Only a user who holds it can ${action}.`, + 403 + ) +} interface GroupUpdateBody { name?: string @@ -271,6 +300,22 @@ async function routes(app: FastifyInstance) { } } + /* + A `manage:groups` holder may edit a group that carries `manage:system` -- name, rules, + redirects, every other permission -- but may not turn that one permission on or off. Granting + it is handing over the instance; revoking it is locking the real administrators out. + */ + if (patch.permissions && !WIKI.models.groups.holdsSystemPermission(req)) { + const held = group.permissions.includes(SYSTEM_PERMISSION) + if (held !== patch.permissions.includes(SYSTEM_PERMISSION)) { + throw new CustomError( + 'groupUpdateSystemPermission', + `Only a user who holds the ${SYSTEM_PERMISSION} permission can grant or revoke it. Every other change to this group is allowed.`, + 403 + ) + } + } + // -> Rule IDs must be unique within the group, as they address the rule client-side if (patch.rules) { const ruleIds = patch.rules.map((r) => r.id) @@ -332,6 +377,12 @@ async function routes(app: FastifyInstance) { return reply.conflict('Cannot delete a system group.') } + // -> Deleting the group removes every member from it, so it is the membership guard's question + const systemGroupRefusal = systemGroupGuard(req, group, 'delete the group') + if (systemGroupRefusal) { + throw systemGroupRefusal + } + try { await WIKI.models.groups.deleteGroup(group.id) return reply.code(204).send() @@ -468,6 +519,12 @@ async function routes(app: FastifyInstance) { if (!user) { return reply.notFound('User does not exist.') } + + const systemGroupRefusal = systemGroupGuard(req, group) + if (systemGroupRefusal) { + throw systemGroupRefusal + } + /* The guests group and the guest account belong to each other and to nothing else — the group is what anonymous visitors hold, and the account is who they are. `guestMembershipViolation` is @@ -535,6 +592,11 @@ async function routes(app: FastifyInstance) { return reply.notFound('User is not assigned to this group.') } + const systemGroupRefusal = systemGroupGuard(req, group) + if (systemGroupRefusal) { + throw systemGroupRefusal + } + // -> Removing the guest account from the guests group would strip anonymous visitors of the // permissions that group carries, with no way to put it back. `unassignUserFromGroup` // refuses that pair as well; this answers it as a conflict rather than as a failure. diff --git a/backend/api/sites.ts b/backend/api/sites.ts index fe572b99c..642b74b03 100644 --- a/backend/api/sites.ts +++ b/backend/api/sites.ts @@ -36,7 +36,7 @@ async function routes(app: FastifyInstance) { '/', { config: { - permissions: ['read:sites', 'read:dashboard'] + permissions: ['read:sites', 'access:admin'] }, schema: { summary: 'List all sites', diff --git a/backend/api/system.ts b/backend/api/system.ts index b2e0e10f2..4d27fefc5 100644 --- a/backend/api/system.ts +++ b/backend/api/system.ts @@ -5,12 +5,65 @@ import { isNil } from 'es-toolkit/predicate' import { gte, sql } from 'drizzle-orm' import { groups as groupsTable, + hooks as hooksTable, pages as pagesTable, tags as tagsTable, users as usersTable } from '../db/schema.ts' import type { FastifyInstance } from 'fastify' +/** + * Every instance connected to this database, with how it is using the connection pool. + * + * There is no instance registry: an instance is only known by the connections it holds, which it + * labels `Wiki.js - :`. Two of those purposes hold a listener rather than + * doing query work, so they are counted apart. + * + * Shared by the list route and the dashboard count, so that the number on the dashboard is the + * number of rows the instances page shows. + */ +async function getInstances(): Promise[]> { + const instRaw = await WIKI.db.execute( + sql`SELECT usename, client_addr, application_name, backend_start, state_change FROM pg_stat_activity WHERE datname = ${WIKI.dbManager.dbName} AND application_name LIKE 'Wiki.js%'` + ) + const insts: Record = {} + for (const inst of instRaw.rows as any[]) { + const instId = inst.application_name.substring(10, 20) + const conType = [':MAIN', ':WORKER'].some((ct) => inst.application_name.endsWith(ct)) + ? 'main' + : 'sub' + // -> `db.execute()` with a raw SQL template returns timestamps as postgres-format strings + // (e.g. `2026-07-25 13:17:36.230177+00`) rather than Dates, which is what the previous + // `DateTime.fromSQL()` call was for. Temporal.Instant.from parses that format as-is, + // including the space separator and the hour-only `+00` offset. Rendered with + // millisecond precision to match the timestamps produced elsewhere. + inst.backend_start = Temporal.Instant.from(inst.backend_start).toString({ + smallestUnit: 'millisecond' + }) + inst.state_change = Temporal.Instant.from(inst.state_change).toString({ + smallestUnit: 'millisecond' + }) + const curInst = insts[instId] ?? { + activeConnections: 0, + activeListeners: 0, + dbFirstSeen: inst.backend_start, + dbLastSeen: inst.state_change + } + insts[instId] = { + id: instId, + activeConnections: + conType === 'main' ? curInst.activeConnections + 1 : curInst.activeConnections, + activeListeners: conType === 'sub' ? curInst.activeListeners + 1 : curInst.activeListeners, + dbUser: inst.usename, + dbFirstSeen: + curInst.dbFirstSeen > inst.backend_start ? inst.backend_start : curInst.dbFirstSeen, + dbLastSeen: curInst.dbLastSeen < inst.state_change ? inst.state_change : curInst.dbLastSeen, + ip: inst.client_addr + } + } + return Object.values(insts) +} + /** * System API Routes */ @@ -22,7 +75,7 @@ async function routes(app: FastifyInstance) { '/info', { config: { - permissions: ['read:dashboard'] + permissions: ['access:admin'] }, schema: { summary: 'System Info', @@ -32,6 +85,11 @@ async function routes(app: FastifyInstance) { description: 'System Info', type: 'object', properties: { + activeWorkers: { + type: 'number', + description: + 'Jobs running right now on every instance combined, one worker slot each.' + }, configFile: { type: 'string' }, @@ -53,6 +111,10 @@ async function routes(app: FastifyInstance) { httpPort: { type: 'number' }, + instancesTotal: { + type: 'number', + description: 'Instances currently connected to this database.' + }, isMailConfigured: { type: 'boolean' }, @@ -103,6 +165,9 @@ async function routes(app: FastifyInstance) { usersTotal: { type: 'number' }, + webhooksTotal: { + type: 'number' + }, workingDirectory: { type: 'string' } @@ -113,6 +178,7 @@ async function routes(app: FastifyInstance) { }, async () => { return { + activeWorkers: await WIKI.models.jobs.countActive(), configFile: path.join(process.cwd(), 'config.yml'), cpuCores: os.cpus().length, currentVersion: WIKI.version, @@ -121,6 +187,7 @@ async function routes(app: FastifyInstance) { groupsTotal: await WIKI.db.$count(groupsTable), hostname: os.hostname(), httpPort: 0, + instancesTotal: (await getInstances()).length, isApiEnabled: WIKI.config.api.isEnabled === true, isMailConfigured: WIKI.config?.mail?.host?.length > 2, isMetricsEnabled: WIKI.config.metrics.isEnabled === true, @@ -139,6 +206,7 @@ async function routes(app: FastifyInstance) { tagsTotal: await WIKI.db.$count(tagsTable), upgradeCapable: !isNil(process.env.UPGRADE_COMPANION), usersTotal: await WIKI.db.$count(usersTable), + webhooksTotal: await WIKI.db.$count(hooksTable), workingDirectory: process.cwd() } } @@ -812,47 +880,7 @@ async function routes(app: FastifyInstance) { } }, async () => { - const instRaw = await WIKI.db.execute( - sql`SELECT usename, client_addr, application_name, backend_start, state_change FROM pg_stat_activity WHERE datname = ${WIKI.dbManager.dbName} AND application_name LIKE 'Wiki.js%'` - ) - const insts: Record = {} - for (const inst of instRaw.rows as any[]) { - const instId = inst.application_name.substring(10, 20) - const conType = [':MAIN', ':WORKER'].some((ct) => inst.application_name.endsWith(ct)) - ? 'main' - : 'sub' - // -> `db.execute()` with a raw SQL template returns timestamps as postgres-format strings - // (e.g. `2026-07-25 13:17:36.230177+00`) rather than Dates, which is what the previous - // `DateTime.fromSQL()` call was for. Temporal.Instant.from parses that format as-is, - // including the space separator and the hour-only `+00` offset. Rendered with - // millisecond precision to match the timestamps produced elsewhere. - inst.backend_start = Temporal.Instant.from(inst.backend_start).toString({ - smallestUnit: 'millisecond' - }) - inst.state_change = Temporal.Instant.from(inst.state_change).toString({ - smallestUnit: 'millisecond' - }) - const curInst = insts[instId] ?? { - activeConnections: 0, - activeListeners: 0, - dbFirstSeen: inst.backend_start, - dbLastSeen: inst.state_change - } - insts[instId] = { - id: instId, - activeConnections: - conType === 'main' ? curInst.activeConnections + 1 : curInst.activeConnections, - activeListeners: - conType === 'sub' ? curInst.activeListeners + 1 : curInst.activeListeners, - dbUser: inst.usename, - dbFirstSeen: - curInst.dbFirstSeen > inst.backend_start ? inst.backend_start : curInst.dbFirstSeen, - dbLastSeen: - curInst.dbLastSeen < inst.state_change ? inst.state_change : curInst.dbLastSeen, - ip: inst.client_addr - } - } - return Object.values(insts) + return getInstances() } ) @@ -863,7 +891,7 @@ async function routes(app: FastifyInstance) { '/checkForUpdate', { config: { - permissions: ['read:dashboard'] + permissions: ['access:admin'] }, schema: { summary: 'Check for Updates', diff --git a/backend/api/users.ts b/backend/api/users.ts index daea313b3..556ebf8ce 100644 --- a/backend/api/users.ts +++ b/backend/api/users.ts @@ -51,6 +51,29 @@ export function whoAmI(req: FastifyRequest): Record { } } +/** + * Refuse a `manage:users` holder any change to a user who is protected by `manage:system`. + * + * `manage:users` is deliberately short of the root: an administrator who can rename, re-group, reset + * the password of, or delete a `manage:system` account can take the instance over through it. Only + * somebody who already holds `manage:system` may touch one. + * + * @returns The refusal to throw, or null when the caller may proceed + */ +async function systemUserGuard(req: FastifyRequest, userId: string): Promise { + if (WIKI.models.groups.holdsSystemPermission(req)) { + return null + } + if (!(await WIKI.models.groups.userHoldsSystemPermission(userId))) { + return null + } + return new CustomError( + 'userSystemProtected', + 'This user belongs to a group with the manage:system permission. Only a user who holds manage:system can modify them.', + 403 + ) +} + /** * Whether self-service profile editing is enabled on the site being browsed. * @@ -1416,6 +1439,11 @@ async function routes(app: FastifyInstance) { return reply.notFound('User does not exist.') } + const systemUserRefusal = await systemUserGuard(req, user.id) + if (systemUserRefusal) { + throw systemUserRefusal + } + // -> Collect only the fields actually provided const patch: UserPatch = {} for (const key of ['name', 'email', 'isActive', 'isVerified', 'meta', 'prefs'] as const) { @@ -1457,6 +1485,24 @@ async function routes(app: FastifyInstance) { } } + /* + Handing somebody `manage:system` by putting them in a group that carries it. Only ADDING is + checked: a user already in such a group is protected by `systemUserGuard` above, which has + refused this request before it gets here. + */ + if (!WIKI.models.groups.holdsSystemPermission(req)) { + const current = await WIKI.models.users.getUserGroupIds(req.params.userId) + const systemGroupIds = await WIKI.models.groups.systemGroupIds() + const added = req.body.groups.filter((id) => !current.includes(id)) + if (added.some((id) => systemGroupIds.includes(id))) { + throw new CustomError( + 'groupMembershipSystemProtected', + 'Only a user who holds the manage:system permission can add a user to a group that has it.', + 403 + ) + } + } + const rootAdminGroupId = WIKI.config.auth.rootAdminGroupId const wasRootAdmin = await WIKI.models.groups.isUserInGroup( rootAdminGroupId, @@ -1548,6 +1594,11 @@ async function routes(app: FastifyInstance) { } }, async (req, reply) => { + const systemUserRefusal = await systemUserGuard(req, req.params.userId) + if (systemUserRefusal) { + throw systemUserRefusal + } + const updated = await WIKI.models.users.setUserPassword({ id: req.params.userId, newPassword: req.body.newPassword, @@ -1596,6 +1647,12 @@ async function routes(app: FastifyInstance) { if (!user) { return reply.notFound('User does not exist.') } + + const systemUserRefusal = await systemUserGuard(req, user.id) + if (systemUserRefusal) { + throw systemUserRefusal + } + // -> The guest account is the only system user, and anonymous access is resolved through it if (user.isSystem) { return reply.conflict('Cannot delete a system user.') diff --git a/backend/locales/en.json b/backend/locales/en.json index 66c99fdb5..acae4e4af 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -193,6 +193,7 @@ "admin.comments.subtitle": "Add discussions to your wiki pages", "admin.comments.title": "Comments", "admin.contribute.title": "Donate", + "admin.dashboard.activeWorkers": "Active Workers", "admin.dashboard.contributeHelp": "We need your help!", "admin.dashboard.contributeLearnMore": "Learn More", "admin.dashboard.contributeSubtitle": "Wiki.js is a free and open source project. There are several ways you can contribute to the project.", @@ -204,8 +205,9 @@ "admin.dashboard.subtitle": "Wiki.js", "admin.dashboard.title": "Dashboard", "admin.dashboard.users": "Users", - "admin.dashboard.versionLatest": "You are running the latest version.", - "admin.dashboard.versionNew": "A new version is available: {version}", + "admin.dashboard.versionChecking": "Checking version...", + "admin.dashboard.versionUpToDate": "Up to date!", + "admin.dashboard.versionUpdateAvailable": "Update available", "admin.dev.flags.title": "Flags", "admin.dev.graphiql.title": "GraphiQL", "admin.dev.title": "Developer Tools", diff --git a/backend/models/groups.ts b/backend/models/groups.ts index 5ab8cf508..72d9eb489 100644 --- a/backend/models/groups.ts +++ b/backend/models/groups.ts @@ -6,6 +6,9 @@ import { resolvePageRule, type RulePageRef } from '../helpers/pageRules.ts' import type { SystemIds } from './types.ts' import type { FastifyRequest } from 'fastify' +/** The permission that bypasses every check, and the one the guards below exist to protect. */ +export const SYSTEM_PERMISSION = 'manage:system' + /** How a rule's `path` is compared against the page path. */ export type GroupRuleMatch = 'START' | 'END' | 'REGEX' | 'TAG' | 'TAGALL' | 'EXACT' @@ -521,6 +524,44 @@ class Groups { ) return total > 0 } + + /** + * Whether the caller itself holds `manage:system`. + * + * `manage:system` is the permission that bypasses every route check, so a `manage:users` / + * `manage:groups` holder who could hand it out — or take it away, or edit the account of somebody + * who has it — would hold it in all but name. The guards built on this answer say so in their own + * words rather than as a bare 403, because "you may manage users, but not THIS user" is not + * something the caller can work out from a generic refusal. + */ + holdsSystemPermission(req: FastifyRequest): boolean { + return this.actorForRequest(req).permissions.includes(SYSTEM_PERMISSION) + } + + /** The ids of every group carrying `manage:system`. */ + async systemGroupIds(): Promise { + const rows = await WIKI.db + .select({ id: groupsTable.id, permissions: groupsTable.permissions }) + .from(groupsTable) + return rows + .filter((row) => ((row.permissions ?? []) as string[]).includes(SYSTEM_PERMISSION)) + .map((row) => row.id) + } + + /** + * Whether a user is protected by `manage:system` — i.e. belongs to any group carrying it. + * + * Membership rather than the session's own list, because the question is asked ABOUT somebody who + * is not the caller and may not be logged in at all. + */ + async userHoldsSystemPermission(userId: string): Promise { + const rows = await WIKI.db + .select({ permissions: groupsTable.permissions }) + .from(userGroups) + .innerJoin(groupsTable, eq(groupsTable.id, userGroups.groupId)) + .where(eq(userGroups.userId, userId)) + return rows.some((row) => ((row.permissions ?? []) as string[]).includes(SYSTEM_PERMISSION)) + } } export const groups = new Groups() diff --git a/backend/models/jobs.ts b/backend/models/jobs.ts index c80887372..fb677160c 100644 --- a/backend/models/jobs.ts +++ b/backend/models/jobs.ts @@ -96,6 +96,20 @@ class Jobs { ) } + /** + * How many jobs are running right now, across every instance. + * + * A job occupies exactly one worker slot from the moment it is claimed — `core/scheduler.ts` + * moves it into the history as `active` and bumps `activeWorkers` in the same step — so this is + * the cluster-wide equivalent of that per-instance counter. + * + * An instance that dies mid-job leaves its row saying `active` until `reapStaleJobs` picks it up, + * which counts here in the meantime, exactly as it still shows under the scheduler's active tab. + */ + async countActive(): Promise { + return WIKI.db.$count(jobHistoryTable, eq(jobHistoryTable.state, 'active')) + } + /** * The cron schedule: which tasks run automatically and how often */ diff --git a/backend/models/users.ts b/backend/models/users.ts index 2c92de967..2a877bd2f 100644 --- a/backend/models/users.ts +++ b/backend/models/users.ts @@ -1360,7 +1360,15 @@ class Users { ) // -> Only once the login has actually succeeded: an attempt stopped by 2FA or a forced password - // change is not a login yet + // change is not a login yet. + // Every login path -- local, provider, passkey, and the 2FA / password-change continuations -- + // ends up here, so this is the one place the stamp belongs. `updatedAt` is deliberately left + // alone: signing in is not an edit of the account. + await WIKI.db + .update(usersTable) + .set({ lastLoginAt: sql`now()` }) + .where(eq(usersTable.id, user.id)) + await WIKI.models.hooks.emit('user:login', { userId: user.id, strategyId, diff --git a/frontend/public/_assets/icons/fluent-done.svg b/frontend/public/_assets/icons/fluent-done.svg new file mode 100644 index 000000000..8f906c6d2 --- /dev/null +++ b/frontend/public/_assets/icons/fluent-done.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/_assets/icons/fluent-double-up.svg b/frontend/public/_assets/icons/fluent-double-up.svg new file mode 100644 index 000000000..3ef757d51 --- /dev/null +++ b/frontend/public/_assets/icons/fluent-double-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/_assets/icons/fluent-refresh.svg b/frontend/public/_assets/icons/fluent-refresh.svg new file mode 100644 index 000000000..d239fdc41 --- /dev/null +++ b/frontend/public/_assets/icons/fluent-refresh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons.generated.js b/frontend/src/assets/icons.generated.js index b95dd51f6..c66aa38c3 100644 --- a/frontend/src/assets/icons.generated.js +++ b/frontend/src/assets/icons.generated.js @@ -5,7 +5,7 @@ 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. - 260 icons. + 263 icons. */ export const BUNDLED_ICONS = { "la:angle-double-right": {"body":"","width":32,"height":32}, @@ -114,6 +114,7 @@ export const BUNDLED_ICONS = { "la:sign-in-alt": {"body":"","width":32,"height":32}, "la:sign-out-alt": {"body":"","width":32,"height":32}, "la:sitemap": {"body":"","width":32,"height":32}, + "la:snowflake": {"body":"","width":32,"height":32}, "la:square": {"body":"","width":32,"height":32}, "la:square-full": {"body":"","width":32,"height":32}, "la:star": {"body":"","width":32,"height":32}, @@ -123,6 +124,7 @@ export const BUNDLED_ICONS = { "la:sun": {"body":"","width":32,"height":32}, "la:sync-alt": {"body":"","width":32,"height":32}, "la:tags": {"body":"","width":32,"height":32}, + "la:tasks": {"body":"","width":32,"height":32}, "la:th-list": {"body":"","width":32,"height":32}, "la:thumbs-down": {"body":"","width":32,"height":32}, "la:thumbs-up": {"body":"","width":32,"height":32}, @@ -151,6 +153,7 @@ export const BUNDLED_ICONS = { "mdi:alert": {"body":"","width":24,"height":24}, "mdi:alert-box": {"body":"","width":24,"height":24}, "mdi:alpha-t-box-outline": {"body":"","width":24,"height":24}, + "mdi:arrow-right": {"body":"","width":24,"height":24}, "mdi:arrow-vertical-lock": {"body":"","width":24,"height":24}, "mdi:basketball": {"body":"","width":24,"height":24}, "mdi:bell": {"body":"","width":24,"height":24}, diff --git a/frontend/src/components/GroupEditOverlay.vue b/frontend/src/components/GroupEditOverlay.vue index 0ce28b641..31ec7fc46 100644 --- a/frontend/src/components/GroupEditOverlay.vue +++ b/frontend/src/components/GroupEditOverlay.vue @@ -33,6 +33,7 @@ :label="t(`common.actions.save`)" icon="la:check" :loading="state.isLoading" + v-if="canManage" @click="save" /> @@ -205,10 +206,17 @@ flat color="indigo" icon="la:file-import" + v-if="canManage" @click="importRules"> {{ t('admin.groups.importRules') }} - +
@@ -298,6 +306,7 @@ color="negative" padding="sm sm" size="md" + v-if="canManage" @click="deleteRule(rule.id)" /> @@ -439,7 +448,7 @@