feat: improve admin dashboard + group permissions fixes

scarlett
NGPixel 1 month ago
parent 49e3598a15
commit 81fc8db4f7
No known key found for this signature in database

@ -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.

@ -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',

@ -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 - <instance id>:<purpose>`. 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<Record<string, any>[]> {
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<string, any> = {}
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<string, any> = {}
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',

@ -51,6 +51,29 @@ export function whoAmI(req: FastifyRequest): Record<string, any> {
}
}
/**
* 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<CustomError | null> {
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.')

@ -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",

@ -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<string[]> {
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<boolean> {
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()

@ -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<number> {
return WIKI.db.$count(jobHistoryTable, eq(jobHistoryTable.state, 'active'))
}
/**
* The cron schedule: which tasks run automatically and how often
*/

@ -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,

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px"><linearGradient id="HoiJCu43QtshzIrYCxOfCa" x1="21.241" x2="3.541" y1="39.241" y2="21.541" gradientUnits="userSpaceOnUse"><stop offset=".108" stop-color="#0d7044"/><stop offset=".433" stop-color="#11945a"/></linearGradient><path fill="url(#HoiJCu43QtshzIrYCxOfCa)" d="M16.599,41.42L1.58,26.401c-0.774-0.774-0.774-2.028,0-2.802l4.019-4.019 c0.774-0.774,2.028-0.774,2.802,0L23.42,34.599c0.774,0.774,0.774,2.028,0,2.802l-4.019,4.019 C18.627,42.193,17.373,42.193,16.599,41.42z"/><linearGradient id="HoiJCu43QtshzIrYCxOfCb" x1="-15.77" x2="26.403" y1="43.228" y2="43.228" gradientTransform="rotate(134.999 21.287 38.873)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#2ac782"/><stop offset="1" stop-color="#21b876"/></linearGradient><path fill="url(#HoiJCu43QtshzIrYCxOfCb)" d="M12.58,34.599L39.599,7.58c0.774-0.774,2.028-0.774,2.802,0l4.019,4.019 c0.774,0.774,0.774,2.028,0,2.802L19.401,41.42c-0.774,0.774-2.028,0.774-2.802,0l-4.019-4.019 C11.807,36.627,11.807,35.373,12.58,34.599z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px"><linearGradient id="oYlkofm8Gqn0Wdt4l2~OIa" x1="-321.604" x2="-335.918" y1="349.007" y2="363.322" gradientTransform="rotate(90 21.494 367.616)" gradientUnits="userSpaceOnUse"><stop offset=".365" stop-color="#199ae0"/><stop offset=".699" stop-color="#1898de"/><stop offset=".819" stop-color="#1691d8"/><stop offset=".905" stop-color="#1186cc"/><stop offset=".974" stop-color="#0a75bc"/><stop offset="1" stop-color="#076cb3"/></linearGradient><path fill="url(#oYlkofm8Gqn0Wdt4l2~OIa)" d="M25.401,3.701l15.483,15.483c0.774,0.774,0.774,2.028,0,2.802l-3.312,3.312 c-0.774,0.774-2.028,0.774-2.802,0L19.287,9.815c-0.774-0.774-0.774-2.028,0-2.802l3.312-3.312 C23.373,2.928,24.627,2.928,25.401,3.701z"/><linearGradient id="oYlkofm8Gqn0Wdt4l2~OIb" x1="-340.656" x2="-322.223" y1="362.162" y2="380.594" gradientTransform="rotate(90 21.494 367.616)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#32bdef"/><stop offset="1" stop-color="#1ea2e4"/></linearGradient><path fill="url(#oYlkofm8Gqn0Wdt4l2~OIb)" d="M28.713,9.815L13.229,25.299c-0.774,0.774-2.028,0.774-2.802,0l-3.312-3.312 c-0.774-0.774-0.774-2.028,0-2.802L22.599,3.701c0.774-0.774,2.028-0.774,2.802,0l3.312,3.312 C29.486,7.787,29.486,9.041,28.713,9.815z"/><linearGradient id="oYlkofm8Gqn0Wdt4l2~OIc" x1="-303.604" x2="-317.918" y1="349.007" y2="363.322" gradientTransform="rotate(90 21.494 367.616)" gradientUnits="userSpaceOnUse"><stop offset=".365" stop-color="#199ae0"/><stop offset=".699" stop-color="#1898de"/><stop offset=".819" stop-color="#1691d8"/><stop offset=".905" stop-color="#1186cc"/><stop offset=".974" stop-color="#0a75bc"/><stop offset="1" stop-color="#076cb3"/></linearGradient><path fill="url(#oYlkofm8Gqn0Wdt4l2~OIc)" d="M25.401,21.701l15.483,15.483c0.774,0.774,0.774,2.028,0,2.802l-3.312,3.312 c-0.774,0.774-2.028,0.774-2.802,0L19.287,27.815c-0.774-0.774-0.774-2.028,0-2.802l3.312-3.312 C23.373,20.928,24.627,20.928,25.401,21.701z"/><linearGradient id="oYlkofm8Gqn0Wdt4l2~OId" x1="-322.656" x2="-304.223" y1="362.162" y2="380.594" gradientTransform="rotate(90 21.494 367.616)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#32bdef"/><stop offset="1" stop-color="#1ea2e4"/></linearGradient><path fill="url(#oYlkofm8Gqn0Wdt4l2~OId)" d="M28.713,27.815L13.229,43.299c-0.774,0.774-2.028,0.774-2.802,0l-3.312-3.312 c-0.774-0.774-0.774-2.028,0-2.802l15.483-15.483c0.774-0.774,2.028-0.774,2.802,0l3.312,3.312 C29.486,25.787,29.486,27.041,28.713,27.815z"/></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px"><radialGradient id="ztVCLBPzVJNm3En8frPzGa" cx="28.283" cy="25.124" r="21.784" gradientUnits="userSpaceOnUse"><stop offset=".266" stop-color="#0071d4"/><stop offset=".535" stop-color="#006fd2"/><stop offset=".673" stop-color="#0068ca"/><stop offset=".782" stop-color="#005dbd"/><stop offset=".876" stop-color="#004daa"/><stop offset=".959" stop-color="#003891"/><stop offset="1" stop-color="#002b82"/></radialGradient><path fill="url(#ztVCLBPzVJNm3En8frPzGa)" d="M43,23c-0.552,0-3.448,0-4,0s-1,0.448-1,1c0,7.732-6.268,14-14,14s-14-6.268-14-14H4 c0,11.046,8.954,20,20,20s20-8.954,20-20C44,23.448,43.552,23,43,23z"/><linearGradient id="ztVCLBPzVJNm3En8frPzGb" x1="27.551" x2="48.131" y1=".565" y2="39.256" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#32bdef"/><stop offset="1" stop-color="#1ea2e4"/></linearGradient><path fill="url(#ztVCLBPzVJNm3En8frPzGb)" d="M33.88,3.384l-5.28,10.387c-0.355,0.699,0.312,1.478,1.057,1.235l7.482-2.439 c0.656-0.214,1.014-0.919,0.8-1.575L35.501,3.51C35.258,2.765,34.235,2.685,33.88,3.384z"/><linearGradient id="ztVCLBPzVJNm3En8frPzGc" x1="13.378" x2="32.773" y1="7.992" y2="44.454" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#32bdef"/><stop offset="1" stop-color="#1ea2e4"/></linearGradient><path fill="url(#ztVCLBPzVJNm3En8frPzGc)" d="M10,24c0-7.732,6.268-14,14-14c2.251,0,4.371,0.544,6.256,1.489l2.681-5.362 C30.245,4.778,27.216,4,24,4C12.954,4,4,12.954,4,24c0,11.046,8.954,20,20,20C19.179,43.252,10,38.89,10,24z"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -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":"<path fill=\"currentColor\" d=\"M9.094 4.781L7.688 6.22l9.78 9.78l-9.78 9.781l1.406 1.438L20.313 16zm7 0L14.687 6.22L24.47 16l-9.782 9.781l1.407 1.438L27.312 16z\"/>","width":32,"height":32},
@ -114,6 +114,7 @@ export const BUNDLED_ICONS = {
"la:sign-in-alt": {"body":"<path fill=\"currentColor\" d=\"M16 4C10.422 4 5.742 7.832 4.406 13H6.47C7.746 8.945 11.53 6 16 6c5.516 0 10 4.484 10 10s-4.484 10-10 10c-4.469 0-8.254-2.945-9.531-7H4.406c1.336 5.168 6.016 9 11.594 9c6.617 0 12-5.383 12-12S22.617 4 16 4m-.656 7.281l-1.438 1.438L16.187 15H4v2h12.188l-2.282 2.281l1.438 1.438l4-4L20.03 16l-.687-.719z\"/>","width":32,"height":32},
"la:sign-out-alt": {"body":"<path fill=\"currentColor\" d=\"M16 4C9.383 4 4 9.383 4 16s5.383 12 12 12c4.05 0 7.64-2.012 9.813-5.094l-1.625-1.156A9.99 9.99 0 0 1 16 26c-5.535 0-10-4.465-10-10S10.465 6 16 6a9.99 9.99 0 0 1 8.188 4.25l1.625-1.156A11.99 11.99 0 0 0 16 4m7.344 7.281l-1.438 1.438L24.188 15H12v2h12.188l-2.282 2.281l1.438 1.438l4-4L28.03 16l-.687-.719z\"/>","width":32,"height":32},
"la:sitemap": {"body":"<path fill=\"currentColor\" d=\"M12 5v8h3v2H5v4H2v8h8v-8H7v-2h8v2h-3v8h8v-8h-3v-2h8v2h-3v8h8v-8h-3v-4H17v-2h3V5zm2 2h4v4h-4zM4 21h4v4H4zm10 0h4v4h-4zm10 0h4v4h-4z\"/>","width":32,"height":32},
"la:snowflake": {"body":"<path fill=\"currentColor\" d=\"M15 3v3.563L12.719 4.28L11.28 5.72L15 9.437v4.813l-4.125-2.469l-1.313-5.094l-1.937.5l.813 3.125L5.374 8.47l-1.031 1.687l3.125 1.875l-3.219.813l.5 1.937l5.125-1.312l4.22 2.53l-4.219 2.531L4.75 17.22l-.5 1.937l3.219.813l-3.125 1.875l1.031 1.687l3.063-1.843l-.813 3.125l1.938.5l1.312-5.094L15 17.75v4.813l-3.719 3.718l1.438 1.438L15 25.437V29h2v-3.563l2.281 2.282l1.438-1.438L17 22.563V17.75l4.125 2.469l1.313 5.093l1.937-.5l-.813-3.125l3.063 1.844l1.031-1.687l-3.125-1.875l3.219-.813l-.5-1.937l-5.125 1.312L17.906 16l4.219-2.531l5.125 1.312l.5-1.937l-3.219-.813l3.125-1.875l-1.031-1.687l-3.063 1.844l.813-3.126l-1.938-.5l-1.312 5.094L17 14.25V9.437l3.719-3.718L19.28 4.28L17 6.563V3z\"/>","width":32,"height":32},
"la:square": {"body":"<path fill=\"currentColor\" d=\"M6 6v20h20V6zm2 2h16v16H8z\"/>","width":32,"height":32},
"la:square-full": {"body":"<path fill=\"currentColor\" d=\"M6 6v20h20V6z\"/>","width":32,"height":32},
"la:star": {"body":"<path fill=\"currentColor\" d=\"m16 2.125l-.906 2.063l-3.25 7.28l-7.938.845l-2.25.25l1.688 1.5l5.906 5.343l-1.656 7.813l-.469 2.187l1.969-1.125l6.906-4l6.906 4l1.969 1.125l-.469-2.187l-1.656-7.813l5.906-5.343l1.688-1.5l-2.25-.25l-7.938-.844l-3.25-7.281zm0 4.906l2.563 5.782l.25.53l.562.063l6.281.656l-4.687 4.22l-.438.405l.125.563l1.313 6.156l-5.469-3.125l-.5-.312l-.5.312l-5.469 3.125l1.313-6.156l.125-.563l-.438-.406l-4.687-4.218l6.281-.657l.563-.062l.25-.531z\"/>","width":32,"height":32},
@ -123,6 +124,7 @@ export const BUNDLED_ICONS = {
"la:sun": {"body":"<path fill=\"currentColor\" d=\"M15 3v5h2V3zM7.5 6.094L6.094 7.5l3.531 3.563l1.438-1.438zm17 0l-3.563 3.531l1.438 1.438L25.906 7.5zM16 9c-3.855 0-7 3.145-7 7s3.145 7 7 7s7-3.145 7-7s-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-5M3 15v2h5v-2zm21 0v2h5v-2zM9.625 20.938L6.094 24.5L7.5 25.906l3.563-3.531zm12.75 0l-1.438 1.437l3.563 3.531l1.406-1.406zM15 24v5h2v-5z\"/>","width":32,"height":32},
"la:sync-alt": {"body":"<path fill=\"currentColor\" d=\"M16 4c-5.113 0-9.383 3.16-11.125 7.625l1.844.75C8.176 8.641 11.71 6 16 6c3.242 0 6.133 1.59 7.938 4H20v2h7V5h-2v3.094A11.94 11.94 0 0 0 16 4m9.281 15.625C23.824 23.359 20.29 26 16 26c-3.277 0-6.156-1.613-7.969-4H12v-2H5v7h2v-3.094C9.188 26.386 12.395 28 16 28c5.113 0 9.383-3.16 11.125-7.625z\"/>","width":32,"height":32},
"la:tags": {"body":"<path fill=\"currentColor\" d=\"m14.594 4l-.313.281l-11 11l-.687.719l.687.719l9 9l.719.687l.719-.687l11-11l.281-.313V4zm.844 2H23v7.563l-10 10L5.437 16zM26 7v2h1v8.156l-9.5 9.438l-1.25-1.25l-1.406 1.406l1.937 1.969l.719.687l.688-.687l10.53-10.407L29 18V7zm-6 1c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1\"/>","width":32,"height":32},
"la:tasks": {"body":"<path fill=\"currentColor\" d=\"M10.293 5.293L7 8.586L5.707 7.293L4.293 8.707L7 11.414l4.707-4.707zM14 7v2h14V7zm0 8v2h14v-2zm0 8v2h14v-2z\"/>","width":32,"height":32},
"la:th-list": {"body":"<path fill=\"currentColor\" d=\"M4 6v20h24V6zm2 2h5v4H6zm7 0h13v4H13zm-7 6h5v4H6zm7 0h13v4H13zm-7 6h5v4H6zm7 0h13v4H13z\"/>","width":32,"height":32},
"la:thumbs-down": {"body":"<path fill=\"currentColor\" d=\"M10.156 6c-1.41 0-2.64.996-2.937 2.375l-2.157 10C4.668 20.223 6.114 22 8 22h5.75l-.188.75c-.203.156-.332.223-.624.625c-.47.64-.938 1.633-.938 2.969C12 27.77 13.29 29 14.906 29h.406l.313-.281L22.406 22H27V6zm0 2H21v12.594l-6.406 6.312c-.422-.082-.594-.254-.594-.562c0-.903.273-1.461.531-1.813s.438-.437.438-.437l.344-.188l.124-.406l.594-2.25l.313-1.25H8c-.66 0-1.105-.574-.969-1.219l2.125-10c.102-.469.524-.781 1-.781M23 8h2v12h-2z\"/>","width":32,"height":32},
"la:thumbs-up": {"body":"<path fill=\"currentColor\" d=\"m16.688 3l-.313.281L9.594 10H5v16h16.844c1.41 0 2.64-.996 2.937-2.375l2.157-10C27.331 11.777 25.887 10 24 10h-5.75l.188-.75c.203-.156.332-.223.625-.625c.468-.64.937-1.633.937-2.969C20 4.23 18.71 3 17.094 3zm.718 2.094c.422.082.594.254.594.562c0 .903-.273 1.461-.531 1.813s-.438.437-.438.437l-.343.188l-.125.406l-.594 2.25l-.313 1.25H24c.66 0 1.105.574.969 1.219l-2.125 10a1.01 1.01 0 0 1-1 .781H11V11.406zM7 12h2v12H7z\"/>","width":32,"height":32},
@ -151,6 +153,7 @@ export const BUNDLED_ICONS = {
"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:alpha-t-box-outline": {"body":"<path fill=\"currentColor\" d=\"M9 7h6v2h-2v8h-2V9H9zM5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m0 2v14h14V5z\"/>","width":24,"height":24},
"mdi:arrow-right": {"body":"<path fill=\"currentColor\" d=\"M4 11v2h12l-5.5 5.5l1.42 1.42L19.84 12l-7.92-7.92L10.5 5.5L16 11z\"/>","width":24,"height":24},
"mdi:arrow-vertical-lock": {"body":"<path fill=\"currentColor\" d=\"M18.8 11V9.5C18.8 8.1 17.4 7 16 7s-2.8 1.1-2.8 2.5V11c-.6 0-1.2.6-1.2 1.2v3.5c0 .7.6 1.3 1.2 1.3h5.5c.7 0 1.3-.6 1.3-1.2v-3.5c0-.7-.6-1.3-1.2-1.3m-1.3 0h-3V9.5c0-.8.7-1.3 1.5-1.3s1.5.5 1.5 1.3zM9 6h3L8 2L4 6h3v12H4l4 4l4-4H9z\"/>","width":24,"height":24},
"mdi:basketball": {"body":"<path fill=\"currentColor\" d=\"M2.34 14.63c.6-.22 1.22-.33 1.88-.33q2.01 0 3.51 1.26L4.59 18.7a10.6 10.6 0 0 1-2.25-4.07M15.56 9.8c1.97 1.47 4.1 1.83 6.38 1.08c.03.21.06.59.06 1.12c0 1.03-.25 2.18-.72 3.45c-.47 1.26-1.05 2.28-1.73 3.05l-6.33-6.31zm-6.79 6.84c1.06 1.53 1.28 3.2.65 5.02c-1.42-.41-2.69-1.05-3.75-1.93zm3.42-3.42l6.31 6.33c-2.17 1.9-4.72 2.7-7.62 2.39c.21-.66.32-1.38.32-2.16c0-.62-.14-1.35-.42-2.18s-.61-1.51-.98-2.04zM8.81 14.5a6.7 6.7 0 0 0-3.23-1.59c-1.22-.23-2.39-.16-3.52.22c-.03-.22-.06-.6-.06-1.13c0-1.03.25-2.18.72-3.45c.47-1.26 1.05-2.28 1.73-3.05l6.66 6.69zm6.75-6.77c-1.34-1.65-1.65-3.45-.93-5.39c.62.16 1.33.46 2.13.92c.79.45 1.44.9 1.94 1.33zm6.1 1.65c-.6.21-1.22.32-1.88.32c-1.09 0-2.14-.32-3.14-.98l3.09-3.05c.88 1.1 1.52 2.33 1.93 3.71m-9.47 1.73L5.5 4.45c2.17-1.9 4.72-2.7 7.63-2.39q-.33.99-.33 2.16c0 .72.16 1.53.49 2.44c.33.9.71 1.62 1.21 2.15z\"/>","width":24,"height":24},
"mdi:bell": {"body":"<path fill=\"currentColor\" d=\"M21 19v1H3v-1l2-2v-6c0-3.1 2.03-5.83 5-6.71V4a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.29c2.97.88 5 3.61 5 6.71v6zm-7 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2\"/>","width":24,"height":24},

@ -33,6 +33,7 @@
:label="t(`common.actions.save`)"
icon="la:check"
:loading="state.isLoading"
v-if="canManage"
@click="save" />
</w-btn-group>
</w-header>
@ -205,10 +206,17 @@
flat
color="indigo"
icon="la:file-import"
v-if="canManage"
@click="importRules">
<w-tooltip>{{ t('admin.groups.importRules') }}</w-tooltip>
</w-btn>
<w-btn unelevated color="primary" icon="la:plus" label="New Rule" @click="newRule" />
<w-btn
v-if="canManage"
unelevated
color="primary"
icon="la:plus"
label="New Rule"
@click="newRule" />
</w-toolbar>
<w-separator />
<div class="p-4">
@ -298,6 +306,7 @@
color="negative"
padding="sm sm"
size="md"
v-if="canManage"
@click="deleteRule(rule.id)" />
</w-card-section>
<w-card-section horizontal>
@ -439,7 +448,7 @@
<template v-for="(perm, idx) of permissions" :key="perm.permission">
<w-item tag="label">
<w-item-section class="items-center" style="flex: 0 0 40px">
<w-icon name="la:comments" color="primary" size="sm" />
<w-icon name="la:snowflake" color="primary" size="sm" />
</w-item-section>
<w-item-section>
<w-item-label>{{ perm.permission }}</w-item-label>
@ -452,6 +461,7 @@
color="primary"
checked-icon="la:check"
unchecked-icon="la:times"
:disable="isSystemPermissionLocked(perm.permission)"
:aria-label="t(`admin.general.allowComments`)" />
</w-item-section>
</w-item>
@ -497,6 +507,7 @@
icon="la:user-plus"
:label="t(`admin.groups.assignUser`)"
color="primary"
v-if="canManage"
@click="assignUser" />
</w-toolbar>
<w-separator />
@ -565,7 +576,7 @@
<!-- refuses to change it either way -->
<w-btn
class="acrylic-btn"
v-if="!props.row.isSystem"
v-if="!props.row.isSystem && canManage"
flat
icon="la:user-minus"
color="accent"
@ -604,6 +615,7 @@ import { notify } from '@/composables/notify'
import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
import { v4 as uuid } from 'uuid'
import { fileOpen, fileSave } from 'browser-fs-access'
@ -618,6 +630,7 @@ const dark = useDark()
const adminStore = useAdminStore()
const siteStore = useSiteStore()
const userStore = useUserStore()
// ROUTER
@ -707,9 +720,23 @@ const permissions = [
restrictedForSystem: true,
disabled: false
},
{
permission: 'read:users',
hint: 'Can view users, but not create or modify them.',
warning: false,
restrictedForSystem: true,
disabled: false
},
{
permission: 'manage:users',
hint: 'Can create / manage users (but not users with administrative permissions)',
hint: 'Can create / manage users (but not users with manage:system permissions)',
warning: false,
restrictedForSystem: true,
disabled: false
},
{
permission: 'read:groups',
hint: 'Can view groups and their permissions, but not create or modify them.',
warning: false,
restrictedForSystem: true,
disabled: false
@ -893,6 +920,23 @@ const groupNameValidation = [(val) => /^[^<>"]+$/.test(val) || t('admin.groups.n
// COMPUTED
/*
`read:groups` opens this overlay read-only: saving a group, and assigning a user to one, need
`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.
*/
const canManage = computed(() => userStore.can('manage:groups'))
/*
`manage:system` is the one permission a `manage:groups` holder may not move: granting it hands over
the instance, revoking it locks the real administrators out. `api/groups.ts` refuses the change
either way, so the toggle is held rather than left to fail on save -- every OTHER permission on such
a group stays editable, which is why this is per-permission and not a read-only group.
*/
function isSystemPermissionLocked(permission) {
return permission === 'manage:system' && !userStore.can('manage:system')
}
const usersTotalPages = computed(() => {
if (state.usersTotal < 1) {
return 0
@ -1049,9 +1093,10 @@ async function save() {
message: t('admin.groups.saveSuccess')
})
} catch (err) {
// -> ky throws above 400 with the reason in the body, which is where the server explains itself
notify({
type: 'negative',
message: err.message
message: apiErrorMessage(err, 'An unexpected error occured.')
})
}
state.isLoading = false

@ -79,6 +79,7 @@ import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
import { computed, reactive, ref } from 'vue'
// PROPS
@ -193,9 +194,10 @@ async function save() {
mustChangePassword: state.userMustChangePassword
})
} catch (err) {
// -> ky throws above 400 with the reason in the body, which is where the server explains itself
notify({
type: 'negative',
message: err.message
message: apiErrorMessage(err, 'An unexpected error occured.')
})
}
state.isLoading = false

@ -29,6 +29,7 @@
icon="la:times"
@click="close" />
<w-btn
v-if="canManage"
push
color="positive"
text-color="white"
@ -350,6 +351,7 @@
flat
icon="la:arrow-circle-right"
color="primary"
v-if="canManage"
@click="changePassword"
:label="t(`common.actions.proceed`)" />
</w-item-section>
@ -422,6 +424,7 @@
flat
icon="la:arrow-circle-right"
color="primary"
v-if="canManage"
@click="invalidateTFA"
:label="t(`common.actions.proceed`)" />
</w-item-section>
@ -472,6 +475,7 @@
flat
icon="la:times"
color="accent"
v-if="canManage"
@click="unassignGroup(grp.id)"
:aria-label="t(`admin.users.unassignGroup`)">
<w-tooltip anchor="center left" self="center right">{{
@ -507,6 +511,7 @@
icon="la:plus"
:label="t(`admin.users.assignGroup`)"
color="primary"
v-if="canManage"
@click="assignGroup" />
</w-item-section>
</w-item>
@ -564,6 +569,7 @@
flat
icon="la:arrow-circle-right"
color="primary"
v-if="canManage"
@click="sendWelcomeEmail"
:label="t(`common.actions.proceed`)" />
</w-item-section>
@ -594,6 +600,7 @@
flat
icon="la:arrow-circle-right"
color="primary"
v-if="canManage"
@click="toggleVerified"
:label="t(`common.actions.proceed`)" />
</w-item-section>
@ -620,6 +627,7 @@
flat
icon="la:arrow-circle-right"
color="primary"
v-if="canManage"
@click="toggleBan"
:label="t(`common.actions.proceed`)" />
</w-item-section>
@ -638,6 +646,7 @@
flat
icon="la:arrow-circle-right"
color="negative"
v-if="canManage"
@click="deleteUser"
:label="t(`common.actions.proceed`)" />
</w-item-section>
@ -665,6 +674,8 @@ import { useAdminStore } from '@/stores/admin'
import { useFlagsStore } from '@/stores/flags'
import { useUserStore } from '@/stores/user'
import { apiErrorMessage } from '@/helpers/apiError'
import UserChangePwdDialog from './UserChangePwdDialog.vue'
import UtilCodeEditor from './UtilCodeEditor.vue'
@ -715,6 +726,13 @@ const timezones = Intl.supportedValuesOf('timeZone')
// COMPUTED
/*
`read:users` opens this overlay read-only: every write below needs `manage:users` (see
`api/users.ts`), so the actions that perform one are hidden rather than left to fail at the API.
The fields stay as they are -- without Save there is nowhere for a typed change to go.
*/
const canManage = computed(() => userStore.can('manage:users'))
const metadata = computed({
get() {
return JSON.stringify(state.user.meta ?? {}, null, 2)
@ -869,9 +887,10 @@ async function save(patch, { silent, keepOpen } = { silent: false, keepOpen: fal
close()
}
} catch (err) {
// -> ky throws above 400 with the reason in the body, which is where the server explains itself
notify({
type: 'negative',
message: err.message
message: apiErrorMessage(err, 'An unexpected error occured.')
})
}
loading.hide()

@ -97,7 +97,10 @@
</w-item-section>
<w-item-section>{{ t('admin.sites.title') }}</w-item-section>
<w-item-section side>
<w-badge color="dark-3" :label="adminStore.sites.length" />
<w-badge
color="dark-3"
:label="adminStore.sites.length"
:class="countBadgeClass(adminStore.sites.length)" />
</w-item-section>
</w-item>
<template v-if="siteSectionShown">
@ -253,25 +256,28 @@
<w-item
to="/_admin/groups"
active-class="bg-primary text-white"
v-if="userStore.can(`manage:groups`)">
v-if="groupsAreVisible">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-people.svg" />
</w-item-section>
<w-item-section>{{ t('admin.groups.title') }}</w-item-section>
<w-item-section side>
<w-badge color="dark-3" :label="adminStore.info.groupsTotal" />
<w-badge
color="dark-3"
:label="adminStore.info.groupsTotal"
:class="countBadgeClass(adminStore.info.groupsTotal)" />
</w-item-section>
</w-item>
<w-item
to="/_admin/users"
active-class="bg-primary text-white"
v-if="userStore.can(`manage:users`)">
<w-item to="/_admin/users" active-class="bg-primary text-white" v-if="usersAreVisible">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-account.svg" />
</w-item-section>
<w-item-section>{{ t('admin.users.title') }}</w-item-section>
<w-item-section side>
<w-badge color="dark-3" :label="adminStore.info.usersTotal" />
<w-badge
color="dark-3"
:label="adminStore.info.usersTotal"
:class="countBadgeClass(adminStore.info.usersTotal)" />
</w-item-section>
</w-item>
</template>
@ -315,6 +321,12 @@
<w-icon name="img:/_assets/icons/fluent-network.svg" />
</w-item-section>
<w-item-section>{{ t('admin.instances.title') }}</w-item-section>
<w-item-section side>
<w-badge
color="dark-3"
:label="adminStore.info.instancesTotal"
:class="countBadgeClass(adminStore.info.instancesTotal)" />
</w-item-section>
</w-item>
<w-item to="/_admin/mail" active-class="bg-primary text-white">
<w-item-section avatar>
@ -404,6 +416,12 @@
<w-icon name="img:/_assets/icons/fluent-lightning-bolt.svg" />
</w-item-section>
<w-item-section>{{ t('admin.webhooks.title') }}</w-item-section>
<w-item-section side>
<w-badge
color="dark-3"
:label="adminStore.info.webhooksTotal"
:class="countBadgeClass(adminStore.info.webhooksTotal)" />
</w-item-section>
</w-item>
<w-item to="/_admin/flags" active-class="bg-primary text-white">
<w-item-section avatar>
@ -496,13 +514,36 @@ const siteSectionShown = computed(() => {
userStore.can('manage:theme')
)
})
/*
`read:*` grants the list and detail routes without the write ones (see `api/users.ts` /
`api/groups.ts`), so the nav entry has to open for it too -- otherwise the permission grants access
to pages nothing links to.
*/
const groupsAreVisible = computed(() => {
return userStore.can('read:groups') || userStore.can('manage:groups')
})
const usersAreVisible = computed(() => {
return userStore.can('read:users') || userStore.can('manage:users')
})
const usersSectionShown = computed(() => {
return userStore.can('manage:groups') || userStore.can('manage:users')
return groupsAreVisible.value || usersAreVisible.value
})
const overlayIsShown = computed(() => {
return Boolean(adminStore.overlay)
})
// METHODS
/*
The nav count badges carry a right border saying whether the thing they count exists at all --
red at zero, green otherwise -- so a section that is empty reads as such without opening it. The
colours are the status lights' own, so the two markers in the column say the same thing the same
way; see the `.count-badge` rules for where they come from.
*/
function countBadgeClass(count) {
return count > 0 ? 'count-badge count-badge--filled' : 'count-badge'
}
// WATCHERS
watch(
@ -580,6 +621,38 @@ onMounted(async () => {
min-width: auto;
}
/*
Nav rows carry two kinds of trailing marker -- a status light and a count badge -- and they have
to read as one column. Both already end on the same right edge; what did not line up is the
height. StatusLight is `height: 100%`, so it takes whatever the row gives it (28px on these
dense rows), while a badge is sized by its own text at 16px, leaving the lights standing 6px
proud above and below every badge in the column.
Pinning them to the badge's band fixes that. It is scoped to the sidebar rather than changed in
StatusLight, because the full-height stripe is the point everywhere else it is used: the storage,
rendering and auth lists put one beside a two-line item, where it reads as an edge marker for the
whole row and has no badge to line up with.
*/
.w-list .status-light {
height: 16px;
}
/*
`$negative` / `$positive` rather than the `--color-*` custom properties, because these have to
match the status lights beside them exactly and StatusLight styles itself from the SCSS
variables -- the custom properties resolve through `--q-*`, which is rewritten at runtime for
per-site theming and would drift away from the lights on any site that sets its own colours.
*/
// -> 5px is StatusLight's own width, so the stripe on a badge and the light on the row below it
// are the same bar of colour rather than two thicknesses of it
.count-badge {
border-right: 5px solid $negative;
&--filled {
border-right-color: $positive;
}
}
// -> The section headings between nav groups; the double shadow is the divider above them
.w-item-label--header {
box-shadow:

@ -2,7 +2,9 @@
<w-page class="admin-dashboard">
<div class="flex flex-wrap p-4 items-center">
<div class="flex-none">
<img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-apps-tab-animated.svg" />
<img
class="admin-icon animated fadeInLeft"
src="/_assets/icons/fluent-apps-tab-animated.svg" />
</div>
<div class="min-w-0 flex-1 pl-4">
<div class="text-h5 text-primary animated fadeInLeft">{{ t('admin.dashboard.title') }}</div>
@ -10,6 +12,28 @@
{{ t('admin.dashboard.subtitle') }}
</div>
</div>
<div class="flex-none flex">
<w-btn
class="mr-2 acrylic-btn"
icon="la:question-circle"
flat
color="grey"
:aria-label="t(`common.actions.viewDocs`)"
:href="siteStore.docsBase + `/admin`"
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>
</div>
</div>
<div class="grid grid-cols-12 px-4 gap-2">
<div class="col-span-12 sm:col-span-6 lg:col-span-3">
@ -57,7 +81,7 @@
:color="actionColor"
icon="la:plus-circle"
:label="t(`common.actions.new`)"
:disable="!userStore.can(`manage:users`)"
:disable="!userStore.can(`manage:groups`)"
@click="newGroup" />
<w-separator vertical />
<w-btn
@ -65,7 +89,7 @@
:color="actionColor"
icon="la:users"
:label="t(`common.actions.manage`)"
:disable="!userStore.can(`manage:users`)"
:disable="!groupsAreVisible"
to="/_admin/groups" />
</w-card-actions>
</w-card>
@ -94,7 +118,7 @@
:color="actionColor"
icon="la:user-friends"
:label="t(`common.actions.manage`)"
:disable="!userStore.can(`manage:users`)"
:disable="!usersAreVisible"
to="/_admin/users" />
</w-card-actions>
</w-card>
@ -137,27 +161,106 @@
</w-card-actions>
</w-card>
</div>
<!-- .col-12.col-lg-9 -->
<!-- q-card -->
<!-- q-card-section --- -->
<div class="col-span-12">
<w-banner
class="bg-positive text-white"
:class="adminStore.isVersionLatest ? `bg-positive` : `bg-warning`"
inline-actions>
<w-icon name="la:check" class="mr-2" />
<span class="font-medium" v-if="adminStore.isVersionLatest"
>Your Wiki.js server is running the latest version!</span
>
<span class="font-medium" v-else
>A new version of Wiki.js is available. Please update to the latest version.</span
>
<template #action v-if="userStore.can(`manage:system`)">
<w-btn flat :label="t(`admin.system.checkForUpdates`)" @click="checkForUpdates" />
<w-separator class="mx-2" vertical dark />
<w-btn flat :label="t(`admin.system.title`)" to="/_admin/system" />
</template>
</w-banner>
<div class="col-span-12 sm:col-span-6 lg:col-span-3">
<w-card>
<w-card-section class="admin-dashboard-card">
<img :src="versionCard.icon" />
<div>
<strong>Wiki.js version</strong>
<small :class="{ pending: versionCard.pending }"
>{{ versionCard.status }}
<i v-if="versionCard.version"
>({{ versionCard.version
}}<w-icon
v-if="versionCard.latestVersion"
name="mdi:arrow-right"
class="mx-1 align-middle" />{{ versionCard.latestVersion }})</i
></small
>
</div>
</w-card-section>
<w-separator />
<w-card-actions align="right">
<w-btn
flat
:color="actionColor"
icon="la:sync-alt"
:label="t(`admin.system.checkForUpdates`)"
:disable="!userStore.can(`manage:system`)"
@click="checkForUpdates" />
<w-separator vertical />
<w-btn
flat
:color="actionColor"
icon="la:info-circle"
:label="t(`admin.system.title`)"
:disable="!userStore.can(`manage:system`)"
to="/_admin/system" />
</w-card-actions>
</w-card>
</div>
<div class="col-span-12 sm:col-span-6 lg:col-span-3">
<w-card>
<w-card-section class="admin-dashboard-card">
<img src="/_assets/icons/fluent-bot.svg" />
<div>
<strong>{{ t('admin.dashboard.activeWorkers') }}</strong>
<span>{{ adminStore.info.activeWorkers }}</span>
</div>
</w-card-section>
<w-separator />
<w-card-actions align="right">
<w-btn
flat
:color="actionColor"
icon="la:tasks"
:label="t(`admin.scheduler.title`)"
:disable="!userStore.can(`manage:system`)"
to="/_admin/scheduler" />
</w-card-actions>
</w-card>
</div>
<div class="col-span-12 sm:col-span-6 lg:col-span-3">
<w-card>
<w-card-section class="admin-dashboard-card">
<img src="/_assets/icons/fluent-network.svg" />
<div>
<strong>{{ t('admin.instances.title') }}</strong>
<span>{{ adminStore.info.instancesTotal }}</span>
</div>
</w-card-section>
<w-separator />
<w-card-actions align="right">
<w-btn
flat
:color="actionColor"
icon="la:server"
:label="t(`common.actions.view`)"
:disable="!userStore.can(`manage:system`)"
to="/_admin/instances" />
</w-card-actions>
</w-card>
</div>
<div class="col-span-12 sm:col-span-6 lg:col-span-3">
<w-card>
<w-card-section class="admin-dashboard-card">
<img src="/_assets/icons/fluent-lightning-bolt.svg" />
<div>
<strong>{{ t('admin.webhooks.title') }}</strong>
<span>{{ adminStore.info.webhooksTotal }}</span>
</div>
</w-card-section>
<w-separator />
<w-card-actions align="right">
<w-btn
flat
:color="actionColor"
icon="la:bolt"
:label="t(`common.actions.manage`)"
:disable="!userStore.can(`manage:system`)"
to="/_admin/webhooks" />
</w-card-actions>
</w-card>
</div>
</div>
</w-page>
@ -166,13 +269,15 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { computed } from 'vue'
import { computed, reactive } from 'vue'
import { useMeta } from '@/composables/meta'
import { dialog } from '@/composables/dialog'
import { useDark } from '@/composables/dark'
import { notify } from '@/composables/notify'
import { useFlagsStore } from '@/stores/flags'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
import { useAdminStore } from '../stores/admin'
@ -185,6 +290,7 @@ import GroupCreateDialog from '@/components/GroupCreateDialog.vue'
const adminStore = useAdminStore()
const flagsStore = useFlagsStore()
const siteStore = useSiteStore()
const userStore = useUserStore()
// COMPOSABLES
@ -198,6 +304,15 @@ const dark = useDark()
*/
const actionColor = computed(() => (dark.isActive ? 'primary-light' : 'primary'))
/*
Manage only opens the list, which `read:*` is enough for -- the same rule the nav entries in
`AdminLayout` use. Creating one is what needs `manage:*`.
*/
const groupsAreVisible = computed(
() => userStore.can('read:groups') || userStore.can('manage:groups')
)
const usersAreVisible = computed(() => userStore.can('read:users') || userStore.can('manage:users'))
// ROUTER
const router = useRouter()
@ -206,6 +321,43 @@ const router = useRouter()
const { t } = useI18n()
// DATA
const state = reactive({
loading: 0
})
// COMPUTED
const versionCard = computed(() => {
switch (adminStore.versionStatus) {
case 'latest':
return {
icon: '/_assets/icons/fluent-done.svg',
status: t('admin.dashboard.versionUpToDate'),
version: adminStore.info.currentVersion,
latestVersion: null,
pending: false
}
case 'outdated':
return {
icon: '/_assets/icons/fluent-double-up.svg',
status: t('admin.dashboard.versionUpdateAvailable'),
version: adminStore.info.currentVersion,
latestVersion: adminStore.info.latestVersion,
pending: false
}
default:
return {
icon: '/_assets/icons/fluent-refresh.svg',
status: t('admin.dashboard.versionChecking'),
version: null,
latestVersion: null,
pending: true
}
}
})
// META
useMeta({
@ -214,6 +366,25 @@ useMeta({
// METHODS
/*
Every card reads from the admin store, which `AdminLayout` fills once on mount -- `fetchInfo` for
the counters on `info`, `fetchSites` for the sites card, which counts the list itself. Refreshing
the dashboard is therefore both of them, not a call of its own.
*/
async function load() {
state.loading++
try {
await Promise.all([adminStore.fetchInfo(), adminStore.fetchSites()])
} catch (err) {
notify({
type: 'negative',
message: 'Failed to refresh the dashboard.',
caption: err.message
})
}
state.loading--
}
function newSite() {
dialog({
component: SiteCreateDialog
@ -280,6 +451,18 @@ function checkForUpdates() {
font-size: 1rem;
font-style: normal;
}
/*
Amber itself (#ffc107) is picked to read on the dark surface; on the white card it lands
around 1.7:1, so the light theme takes the darker end of the ramp instead.
*/
&.pending {
color: var(--color-amber-9);
@at-root .body--dark & {
color: var(--color-amber);
}
}
}
}

@ -40,6 +40,7 @@
<w-tooltip>{{ t(`common.actions.refresh`) }}</w-tooltip>
</w-btn>
<w-btn
v-if="canManage"
unelevated
icon="la:plus"
:label="t(`admin.groups.create`)"
@ -88,12 +89,13 @@
class="acrylic-btn mr-2"
flat
:to="`/_admin/groups/` + props.row.id"
icon="la:pen"
:icon="canManage ? `la:pen` : `la:eye`"
:color="dark.isActive ? `indigo-4` : `indigo`"
:label="t(`common.actions.edit`)"
:label="canManage ? t(`common.actions.edit`) : t(`common.actions.view`)"
no-caps />
<w-btn
class="acrylic-btn"
v-if="canManage"
flat
icon="la:trash"
:color="props.row.isSystem ? `grey` : `negative`"
@ -110,7 +112,7 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { onBeforeUnmount, onMounted, reactive, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useDark } from '@/composables/dark'
@ -121,6 +123,7 @@ import { dialog } from '@/composables/dialog'
import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
import GroupCreateDialog from '../components/GroupCreateDialog.vue'
import GroupDeleteDialog from '../components/GroupDeleteDialog.vue'
@ -133,6 +136,7 @@ const dark = useDark()
const adminStore = useAdminStore()
const siteStore = useSiteStore()
const userStore = useUserStore()
// ROUTER
@ -149,6 +153,14 @@ useMeta({
title: t('admin.groups.title')
})
// COMPUTED
/*
`read:groups` reaches this page too (see the nav in `AdminLayout`), and everything that writes needs
`manage:groups` -- so the controls behind it are hidden rather than left to fail at the API.
*/
const canManage = computed(() => userStore.can('manage:groups'))
// DATA
const state = reactive({

@ -41,6 +41,7 @@
</w-btn>
<w-btn
class="mr-2"
v-if="canManage"
icon="la:user-cog"
unelevated
color="secondary"
@ -49,6 +50,7 @@
<user-defaults-menu />
</w-btn>
<w-btn
v-if="canManage"
unelevated
icon="la:plus"
:label="t(`admin.users.create`)"
@ -110,9 +112,9 @@
v-if="!props.row.isSystem"
flat
:to="`/_admin/users/` + props.row.id"
icon="la:pen"
:icon="canManage ? `la:pen` : `la:eye`"
:color="dark.isActive ? `indigo-4` : `indigo`"
:label="t(`common.actions.edit`)"
:label="canManage ? t(`common.actions.edit`) : t(`common.actions.view`)"
no-caps />
<!--
Disabled rather than hidden for your own account: the row is yours and the action
@ -121,7 +123,7 @@
-->
<w-btn
class="acrylic-btn"
v-if="!props.row.isSystem"
v-if="!props.row.isSystem && canManage"
flat
icon="la:trash"
color="negative"
@ -151,7 +153,7 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { onBeforeUnmount, onMounted, reactive, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useDark } from '@/composables/dark'
@ -196,6 +198,14 @@ useMeta({
title: t('admin.users.title')
})
// COMPUTED
/*
`read:users` reaches this page too (see the nav in `AdminLayout`), and everything that writes 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'))
// DATA
const state = reactive({

@ -10,10 +10,13 @@ export const useAdminStore = defineStore('admin', {
info: {
currentVersion: 'n/a',
latestVersion: 'n/a',
activeWorkers: 0,
groupsTotal: 0,
instancesTotal: 0,
pagesTotal: 0,
tagsTotal: 0,
usersTotal: 0,
webhooksTotal: 0,
loginsPastDay: 0,
isApiEnabled: false,
isMailConfigured: false,
@ -26,16 +29,23 @@ export const useAdminStore = defineStore('admin', {
locales: [{ code: 'en', name: 'English' }]
}),
getters: {
isVersionLatest: (state) => {
/**
* `pending` until `fetchInfo` has both versions -- neither `latest` nor `outdated` can be
* claimed before the server has answered.
*/
versionStatus: (state) => {
if (
!state.info.currentVersion ||
!state.info.latestVersion ||
state.info.currentVersion === 'n/a' ||
state.info.latestVersion === 'n/a'
) {
return false
return 'pending'
}
return semverGte(state.info.currentVersion, state.info.latestVersion)
return semverGte(state.info.currentVersion, state.info.latestVersion) ? 'latest' : 'outdated'
},
isVersionLatest() {
return this.versionStatus === 'latest'
}
},
actions: {
@ -45,9 +55,12 @@ export const useAdminStore = defineStore('admin', {
},
async fetchInfo() {
const resp = await API_CLIENT.get('system/info').json()
this.info.activeWorkers = resp?.activeWorkers ?? 0
this.info.groupsTotal = resp?.groupsTotal ?? 0
this.info.instancesTotal = resp?.instancesTotal ?? 0
this.info.tagsTotal = resp?.tagsTotal ?? 0
this.info.usersTotal = resp?.usersTotal ?? 0
this.info.webhooksTotal = resp?.webhooksTotal ?? 0
this.info.loginsPastDay = resp?.loginsPastDay ?? 0
this.info.currentVersion = resp?.currentVersion ?? 'n/a'
this.info.latestVersion = resp?.latestVersion ?? 'n/a'

Loading…
Cancel
Save