refactor: wire most site and users admin views

scarlett
NGPixel 2 months ago
parent ee7a15fbd6
commit 6e8fe2b558
No known key found for this signature in database

@ -73,7 +73,8 @@ Quasar app on plain Vite (not Quasar CLI). `src/main.js` wires it up manually: r
- `src/boot/` — one-time app initializers: `api.js` (creates the `ky` client with JWT refresh, exposed
as the `API_CLIENT` global), `components.js` (global components), `eventbus.js` (`EVENT_BUS` global,
mitt), `externals.js`, `i18n.js`, `monaco.js`.
mitt), `externals.js`, `i18n.js`, `monaco.js`, `temporal.js` (conditionally polyfills `Temporal`,
awaited before anything else in `main.js`).
- `src/router/``index.js` (router factory) and `routes.js` (the full route table; page components
are lazily imported).
- `src/layouts/``MainLayout`, `AdminLayout`, `AuthLayout`, `ProfileLayout`.
@ -210,12 +211,31 @@ the standard-style space before parens (`function initializeRouter ()`); new and
be oxfmt-formatted, but don't reformat untouched files as drive-by changes.
Each workspace has its own `.oxlintrc.json` — the backend declares the `WIKI` global and node env;
the frontend adds the `vue` plugin and the `API_CLIENT` / `EVENT_BUS` globals. Only the `correctness`
category is an error.
the frontend adds the `vue` plugin and the `API_CLIENT` / `EVENT_BUS` / `Temporal` globals. Only the
`correctness` category is an error.
Both tools handle `.ts` with no extra configuration, and the backend's oxlint config already enables
the `typescript` plugin. oxlint does not type-check — run `npm run typecheck` for that.
### Utilities and dates
These apply to **every workspace**, `frontend/` included — not just the backend.
- **Use `es-toolkit`, not `lodash-es`.** Installed in both `backend/` and `frontend/`.
- **Use the native `Temporal` API, not luxon.** See [Backend patterns](#backend-patterns) for the
Temporal gotchas worth knowing; they apply on the frontend too.
- **luxon and lodash-es are being removed entirely.** The migration is gradual: when you touch a file
that imports either one, convert that file's usages as part of the same change — but don't sweep
through untouched files as a drive-by. Once the last usage is gone, both dependencies get dropped.
- Prefer real es-toolkit subpath exports (`es-toolkit/object`, `es-toolkit/array`,
`es-toolkit/predicate`) over `es-toolkit/compat`. Two lodash helpers are compat-only and have direct
equivalents: `defaultsDeep(source, defaults)``toMerged(defaults, source)` (note the argument
order flips) and `toSafeInteger(x)``Number.parseInt(x, 10)`.
- On the frontend `Temporal` is a global, declared in `.oxlintrc.json`. `src/boot/temporal.js`
dynamically imports `temporal-polyfill` for browsers without native support (Safari, as of
mid-2026) and is awaited first in `main.js`. The polyfill is a lazy chunk (~21 kB gzipped) that
browsers with native `Temporal` never download.
### Backend patterns
- **The `WIKI` global.** Set up in `index.ts`, typed in `types/global.d.ts`, available everywhere
@ -236,10 +256,8 @@ the `typescript` plugin. oxlint does not type-check — run `npm run typecheck`
failures into `{ ok, error, statusCode, message }` JSON.
- **Schema changes**: edit `db/schema.ts`, then `npm run db-generate` and commit the generated
migration. Never hand-edit an existing migration.
- Prefer **es-toolkit** over lodash on the backend.
- **Dates use the native `Temporal` API**, not luxon (which is no longer a backend dependency —
`frontend/` still uses it). `Temporal` is a global in Node 26 and is typed by the TS 7 lib, so it
needs no import. Three things to know:
- **Dates use the native `Temporal` API**, not luxon (no longer a backend dependency). `Temporal` is a
global in Node 26 and is typed by the TS 7 lib, so it needs no import. Four things to know:
- `Temporal.Instant` accepts **exact time units only**`add({ days: 1 })` throws. Since these are
all UTC instants, use `{ hours: 24 }`.
- Temporal types have no `valueOf`, so `a < b` **throws**. Compare with
@ -260,7 +278,9 @@ the `typescript` plugin. oxlint does not type-check — run `npm run typecheck`
config, so no import needed) — e.g. `await API_CLIENT.get('sites').json()`. It handles the `/_api`
prefix and JWT refresh.
- Cross-component messaging uses the `EVENT_BUS` global (mitt).
- State lives in Pinia option stores; `lodash-es` is the utility library here.
- State lives in Pinia option stores. For utilities and dates use `es-toolkit` and `Temporal` — see
[Utilities and dates](#utilities-and-dates); the `lodash-es` and `luxon` still present in older
files are on their way out.
### GraphQL is being removed

@ -12,6 +12,8 @@ async function routes(app: FastifyInstance) {
{
schema: {
summary: 'List all site authentication strategies',
description:
'Ordered by the position configured for the site. `activeStrategy` holds the per-instance settings, nested under it `strategy` holds the module definition.',
tags: ['Authentication'],
params: {
type: 'object',
@ -20,7 +22,8 @@ async function routes(app: FastifyInstance) {
type: 'string',
format: 'uuid'
}
}
},
required: ['siteId']
},
querystring: {
type: 'object',
@ -30,6 +33,61 @@ async function routes(app: FastifyInstance) {
default: false
}
}
},
response: {
200: {
description: 'List of site authentication strategies',
type: 'array',
items: {
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid'
},
order: {
type: 'integer'
},
isVisible: {
type: 'boolean'
},
activeStrategy: {
type: 'object',
properties: {
displayName: {
type: 'string'
},
registration: {
type: 'boolean'
},
strategy: {
type: 'object',
properties: {
key: {
type: 'string'
},
title: {
type: 'string'
},
icon: {
type: 'string'
},
color: {
type: 'string'
},
useForm: {
type: 'boolean'
},
usernameType: {
type: 'string'
}
}
}
}
}
}
}
}
}
}
},
@ -39,19 +97,28 @@ async function routes(app: FastifyInstance) {
return reply.badRequest('Invalid Site ID')
}
const activeStrategies = await WIKI.models.authentication.getStrategies({ enabledOnly: true })
// -> A site created before it had strategies configured has no list at all
const configuredStrategies = site.config.authStrategies ?? []
const siteStrategies = activeStrategies
.map((str: any) => {
const authModule = WIKI.data.authentication.find((m: any) => m.key === str.module)
const siteStr = site.config.authStrategies.find((s: any) => s.id === str.id) || {}
const siteStr = configuredStrategies.find((s: any) => s.id === str.id) || {}
return {
id: str.id,
displayName: str.displayName,
useForm: authModule.useForm,
usernameType: authModule.usernameType,
color: authModule.color,
icon: authModule.icon,
order: siteStr.order ?? 0,
isVisible: siteStr.isVisible ?? false
isVisible: siteStr.isVisible ?? false,
activeStrategy: {
displayName: str.displayName,
registration: str.registration,
strategy: {
key: authModule?.key ?? str.module,
title: authModule?.title ?? str.module,
icon: authModule?.icon ?? '',
color: authModule?.color ?? 'primary',
useForm: authModule?.useForm ?? false,
usernameType: authModule?.usernameType ?? 'email'
}
}
}
})
.sort((a: any, b: any) => a.order - b.order)

@ -0,0 +1,194 @@
import type { FastifyInstance } from 'fastify'
/**
* Blocks API Routes
*/
async function routes(app: FastifyInstance) {
/**
* LIST SITE BLOCKS
*/
app.get<{ Params: { siteId: string } }>(
'/sites/:siteId/blocks',
{
config: {
permissions: ['read:sites', 'manage:sites']
},
schema: {
summary: 'List the blocks available to a site',
description:
'Built-in blocks are registered from the compiled block manifest, so the list reflects what is actually installed.',
tags: ['Blocks'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
},
response: {
200: {
description: 'List of site blocks',
type: 'array',
items: { $ref: 'Block#' }
}
}
}
},
async (req, reply) => {
const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
if (!site) {
return reply.notFound('Site does not exist.')
}
return WIKI.models.blocks.getSiteBlocks(req.params.siteId)
}
)
/**
* SET SITE BLOCKS STATE
*/
app.put<{
Params: { siteId: string }
Body: { states: { id: string; isEnabled: boolean }[] }
}>(
'/sites/:siteId/blocks',
{
config: {
permissions: ['manage:sites']
},
schema: {
summary: 'Enable or disable site blocks',
description: 'Only the blocks listed are affected; any others keep their current state.',
tags: ['Blocks'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
},
body: {
type: 'object',
required: ['states'],
properties: {
states: {
type: 'array',
items: {
type: 'object',
required: ['id', 'isEnabled'],
properties: {
id: {
type: 'string',
format: 'uuid'
},
isEnabled: {
type: 'boolean'
}
}
}
}
}
},
response: {
200: {
description: 'Blocks state updated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
updated: {
type: 'integer',
description:
'How many block rows were written. A block already in the requested state still counts.'
}
}
}
}
}
},
async (req, reply) => {
const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
if (!site) {
return reply.notFound('Site does not exist.')
}
try {
const updated = await WIKI.models.blocks.setBlocksState(req.params.siteId, req.body.states)
return {
ok: true,
message: 'Blocks state updated successfully.',
updated
}
} catch (err: any) {
WIKI.logger.warn(err)
return reply.internalServerError()
}
}
)
/**
* DELETE CUSTOM BLOCK
*/
app.delete<{ Params: { siteId: string; blockId: string } }>(
'/sites/:siteId/blocks/:blockId',
{
config: {
permissions: ['manage:sites']
},
schema: {
summary: 'Delete a custom block',
description:
'Only custom blocks can be deleted. Built-in blocks are registered from disk and would reappear on the next sync.',
tags: ['Blocks'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
},
blockId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId', 'blockId']
},
response: {
204: {
description: 'Block deleted successfully'
}
}
}
},
async (req, reply) => {
const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
if (!site) {
return reply.notFound('Site does not exist.')
}
const siteBlocks = await WIKI.models.blocks.getSiteBlocks(req.params.siteId)
const block = siteBlocks.find((b) => b.id === req.params.blockId)
if (!block) {
return reply.notFound('Block does not exist.')
}
if (!block.isCustom) {
return reply.conflict('Cannot delete a built-in block.')
}
await WIKI.models.blocks.deleteCustomBlock(req.params.siteId, req.params.blockId)
return reply.code(204).send()
}
)
}
export default routes

@ -41,6 +41,71 @@ async function routes(app: FastifyInstance) {
}
)
/**
* CREATE GROUP
*/
app.post<{ Body: { name: string } }>(
'/',
{
config: {
permissions: ['write:groups', 'manage:groups']
},
schema: {
summary: 'Create a new group',
description:
'Creates a non-system group, seeded with the same starting permissions and default rule as the built-in `Users` group.',
tags: ['Groups'],
body: {
type: 'object',
required: ['name'],
properties: {
name: {
type: 'string',
minLength: 1,
maxLength: 255
}
},
examples: [{ name: 'Editors' }]
},
response: {
200: {
description: 'Group created successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
id: {
type: 'string',
format: 'uuid'
}
}
}
}
}
},
async (req, reply) => {
if (!/^[^<>"]+$/.test(req.body.name)) {
throw new CustomError('groupCreateInvalidName', 'Invalid Group Name')
}
try {
const id = await WIKI.models.groups.createGroup(req.body.name)
return {
ok: true,
message: 'Group created successfully.',
id
}
} catch (err: any) {
WIKI.logger.warn(err)
return reply.internalServerError()
}
}
)
/**
* GET SINGLE GROUP
*/
@ -192,12 +257,18 @@ async function routes(app: FastifyInstance) {
}
// -> The root administrators group must keep its permissions, or the instance becomes
// unmanageable with no way to grant `manage:system` back.
// unmanageable with no way to grant `manage:system` back. Resending the current set is
// allowed, so that a client editing other fields can still submit the whole group.
if (patch.permissions && group.id === WIKI.config.auth.rootAdminGroupId) {
throw new CustomError(
'groupUpdateRootAdminPermissions',
'Cannot modify the permissions of the root administrators group.'
)
const isUnchanged =
patch.permissions.length === group.permissions.length &&
patch.permissions.every((p) => group.permissions.includes(p))
if (!isUnchanged) {
throw new CustomError(
'groupUpdateRootAdminPermissions',
'Cannot modify the permissions of the root administrators group.'
)
}
}
// -> Rule IDs must be unique within the group, as they address the rule client-side
@ -355,6 +426,8 @@ async function routes(app: FastifyInstance) {
},
schema: {
summary: 'Assign a user to a group',
description:
'System users (the guest account) cannot be assigned: their group membership is fixed at install time.',
tags: ['Groups'],
params: {
type: 'object',
@ -391,9 +464,15 @@ async function routes(app: FastifyInstance) {
if (!group) {
return reply.notFound('Group does not exist.')
}
if (!(await WIKI.models.users.getById(req.params.userId))) {
const user = await WIKI.models.users.getById(req.params.userId)
if (!user) {
return reply.notFound('User does not exist.')
}
// -> The guest account is the only system user, and it must stay in the guests group alone:
// its permissions are what anonymous visitors get.
if (user.isSystem) {
return reply.conflict('Cannot assign a system user to a group.')
}
const assigned = await WIKI.models.groups.assignUserToGroup(group.id, req.params.userId)
if (!assigned) {
@ -419,7 +498,7 @@ async function routes(app: FastifyInstance) {
schema: {
summary: 'Unassign a user from a group',
description:
'Removes the user from the group. The last remaining user cannot be removed from the root administrators group.',
'Removes the user from the group. The last remaining user cannot be removed from the root administrators group, and system users (the guest account) cannot be unassigned at all.',
tags: ['Groups'],
params: {
type: 'object',
@ -451,6 +530,13 @@ async function routes(app: FastifyInstance) {
return reply.notFound('User is not assigned to this group.')
}
// -> 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
const user = await WIKI.models.users.getById(req.params.userId)
if (user?.isSystem) {
return reply.conflict('Cannot unassign a system user from a group.')
}
// -> Emptying the root administrators group would lock everyone out of system management
if (group.id === WIKI.config.auth.rootAdminGroupId) {
if ((await WIKI.models.groups.countUsersInGroup(group.id)) <= 1) {

@ -5,14 +5,18 @@ import type { FastifyInstance } from 'fastify'
*/
async function routes(app: FastifyInstance) {
// Register schemas
await import('./schemas/block.ts').then((m) => m.registerSchemas(app))
await import('./schemas/group.ts').then((m) => m.registerSchemas(app))
await import('./schemas/mail.ts').then((m) => m.registerSchemas(app))
await import('./schemas/site.ts').then((m) => m.registerSchemas(app))
await import('./schemas/user.ts').then((m) => m.registerSchemas(app))
// Register routes
app.register(import('./authentication.ts'))
app.register(import('./blocks.ts'))
app.register(import('./groups.ts'), { prefix: '/groups' })
app.register(import('./locales.ts'), { prefix: '/locales' })
app.register(import('./mail.ts'), { prefix: '/mail' })
app.register(import('./pages.ts'))
app.register(import('./sites.ts'), { prefix: '/sites' })
app.register(import('./system.ts'), { prefix: '/system' })

@ -0,0 +1,144 @@
import type { FastifyInstance } from 'fastify'
/**
* Placeholder sent to the client in place of the stored SMTP password. Sending it back unchanged
* leaves the stored password alone.
*/
const PASSWORD_MASK = '********'
/**
* Mail settings, stored as the `mail` key of the settings table.
*/
const MAIL_CONFIG_KEYS = [
'senderName',
'senderEmail',
'defaultBaseURL',
'host',
'port',
'name',
'secure',
'verifySSL',
'user',
'pass',
'useDKIM',
'dkimDomainName',
'dkimKeySelector',
'dkimPrivateKey'
] as const
/**
* Mail API Routes
*/
async function routes(app: FastifyInstance) {
/**
* GET MAIL CONFIG
*/
app.get(
'/config',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Get mail configuration',
tags: ['Mail'],
response: {
200: {
description: 'Mail configuration',
type: 'object',
$ref: 'MailConfig#'
}
}
}
},
async () => {
return {
...WIKI.config.mail,
pass: WIKI.config.mail?.pass?.length > 0 ? PASSWORD_MASK : ''
}
}
)
/**
* UPDATE MAIL CONFIG
*/
app.put<{
Body: {
senderName?: string
senderEmail?: string
defaultBaseURL?: string
host?: string
port?: number
name?: string
secure?: boolean
verifySSL?: boolean
user?: string
pass?: string
useDKIM?: boolean
dkimDomainName?: string
dkimKeySelector?: string
dkimPrivateKey?: string
}
}>(
'/config',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Update mail configuration',
tags: ['Mail'],
body: {
$ref: 'MailConfig#'
},
response: {
200: {
description: 'Mail configuration updated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
const patch: Record<string, any> = {}
for (const key of MAIL_CONFIG_KEYS) {
if (req.body[key] !== undefined) {
patch[key] = req.body[key]
}
}
// -> Base URLs are used to build links in emails, always without a trailing slash
if (typeof patch.defaultBaseURL === 'string') {
patch.defaultBaseURL = patch.defaultBaseURL.replace(/\/+$/, '')
}
// -> The client only ever receives a masked password, so an unchanged one must not be stored
if (patch.pass === PASSWORD_MASK) {
delete patch.pass
}
const previousConfig = WIKI.config.mail
WIKI.config.mail = { ...previousConfig, ...patch }
if (!(await WIKI.configSvc.saveToDb(['mail']))) {
WIKI.config.mail = previousConfig
return reply.internalServerError('Failed to save mail configuration.')
}
return {
ok: true,
message: 'Mail configuration updated successfully.'
}
}
)
}
export default routes

@ -0,0 +1,42 @@
import type { FastifyInstance } from 'fastify'
export async function registerSchemas(app: FastifyInstance): Promise<void> {
/**
* BLOCK
*/
app.addSchema({
$id: 'Block',
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid'
},
block: {
type: 'string',
description: 'Element suffix — the block renders as `<block-{block}>`.'
},
name: {
type: 'string'
},
description: {
type: 'string'
},
icon: {
type: 'string',
description: 'Blueprint icon name, resolved as `/_assets/icons/ultraviolet-{icon}.svg`.'
},
isEnabled: {
type: 'boolean'
},
isCustom: {
type: 'boolean',
description: 'False for blocks registered from the compiled block manifest.'
},
config: {
type: 'object',
additionalProperties: true
}
}
})
}

@ -0,0 +1,68 @@
import type { FastifyInstance } from 'fastify'
export async function registerSchemas(app: FastifyInstance): Promise<void> {
/**
* MAIL CONFIG
*/
app.addSchema({
$id: 'MailConfig',
type: 'object',
properties: {
senderName: {
type: 'string',
maxLength: 255
},
senderEmail: {
type: 'string',
maxLength: 255
},
defaultBaseURL: {
type: 'string',
maxLength: 255
},
host: {
type: 'string',
maxLength: 255
},
port: {
type: 'integer',
minimum: 1,
maximum: 65535
},
name: {
type: 'string',
maxLength: 255
},
secure: {
type: 'boolean'
},
verifySSL: {
type: 'boolean'
},
user: {
type: 'string',
maxLength: 255
},
pass: {
type: 'string',
description:
'Returned masked as `********` when a password is stored. Send the masked value back unchanged to keep the stored password.',
maxLength: 255
},
useDKIM: {
type: 'boolean'
},
dkimDomainName: {
type: 'string',
maxLength: 255
},
dkimKeySelector: {
type: 'string',
maxLength: 255
},
dkimPrivateKey: {
type: 'string'
}
}
})
}

@ -84,11 +84,27 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
profile: {
type: 'boolean'
},
reasonForChange: {
type: 'string',
enum: ['off', 'optional', 'required']
},
search: {
type: 'boolean'
}
}
},
uploads: {
type: 'object',
properties: {
conflictBehavior: {
type: 'string',
enum: ['overwrite', 'reject', 'new']
},
normalizeFilename: {
type: 'boolean'
}
}
},
logoUrl: {
type: 'string'
},
@ -109,6 +125,53 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
}
}
},
auth: {
type: 'object',
description: 'Login experience for this site. Redirects can be overridden per group.',
properties: {
autoLogin: {
type: 'boolean'
},
bypassUnauthorized: {
type: 'boolean'
},
hideLocal: {
type: 'boolean'
},
loginRedirect: {
type: 'string',
maxLength: 255
},
welcomeRedirect: {
type: 'string',
maxLength: 255
},
logoutRedirect: {
type: 'string',
maxLength: 255
}
}
},
authStrategies: {
type: 'array',
description: 'Which authentication strategies this site offers, in display order.',
items: {
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid'
},
order: {
type: 'integer',
minimum: 0
},
isVisible: {
type: 'boolean'
}
}
}
},
locales: {
type: 'object',
properties: {
@ -120,6 +183,9 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
items: {
type: 'string'
}
},
forcePrefix: {
type: 'boolean'
}
}
},
@ -145,15 +211,44 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
},
editors: {
type: 'object',
description:
'Per-editor state. `config` is free-form and specific to each editor implementation.',
properties: {
asciidoc: {
type: 'boolean'
type: 'object',
properties: {
isActive: {
type: 'boolean'
},
config: {
type: 'object',
additionalProperties: true
}
}
},
markdown: {
type: 'boolean'
type: 'object',
properties: {
isActive: {
type: 'boolean'
},
config: {
type: 'object',
additionalProperties: true
}
}
},
wysiwyg: {
type: 'boolean'
type: 'object',
properties: {
isActive: {
type: 'boolean'
},
config: {
type: 'object',
additionalProperties: true
}
}
}
}
},
@ -165,7 +260,8 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
},
codeBlocksTheme: {
type: 'string',
format: 'hexcolor'
description: 'Name of a highlight.js stylesheet, e.g. `github-dark`.',
maxLength: 255
},
colorPrimary: {
type: 'string',

@ -56,6 +56,30 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
}
})
/**
* USER DEFAULTS - Instance-wide defaults applied to new users
*/
app.addSchema({
$id: 'UserDefaults',
type: 'object',
properties: {
timezone: {
type: 'string',
description: 'IANA time zone name, e.g. `America/New_York`.',
maxLength: 255
},
dateFormat: {
type: 'string',
description: 'Empty string means the locale default.',
enum: ['', 'DD/MM/YYYY', 'DD.MM.YYYY', 'MM/DD/YYYY', 'YYYY-MM-DD', 'YYYY/MM/DD']
},
timeFormat: {
type: 'string',
enum: ['12h', '24h']
}
}
})
/**
* USER - All fields
*/
@ -77,10 +101,47 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
additionalProperties: true
},
auth: {
type: 'string'
type: 'array',
description:
'Authentication providers linked to this user. Secrets are never included — `config.isPasswordSet` and `config.tfaIsActive` report their state instead.',
items: {
type: 'object',
properties: {
authId: {
type: 'string',
format: 'uuid'
},
authName: {
type: 'string'
},
strategyKey: {
type: 'string'
},
strategyIcon: {
type: 'string'
},
config: {
type: 'object',
additionalProperties: true
}
}
}
},
passkeys: {
type: 'string'
groups: {
type: 'array',
description: 'Groups this user belongs to.',
items: {
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid'
},
name: {
type: 'string'
}
}
}
}
}
}

@ -2,6 +2,32 @@ import { validate as uuidValidate } from 'uuid'
import { CustomError } from '../helpers/common.ts'
import type { FastifyInstance } from 'fastify'
/**
* Site properties stored in the `config` JSONB column rather than as their own table column.
* Anything listed here is merged into the existing config on update.
*/
const SITE_CONFIG_KEYS = [
'title',
'description',
'company',
'contentLicense',
'footerExtra',
'pageExtensions',
'pageCasing',
'logoText',
'sitemap',
'discoverable',
'auth',
'authStrategies',
'defaults',
'editors',
'features',
'locales',
'robots',
'theme',
'uploads'
] as const
/**
* Sites API Routes
*/
@ -30,12 +56,7 @@ async function routes(app: FastifyInstance) {
...s.config,
id: s.id,
hostname: s.hostname,
isEnabled: s.isEnabled,
editors: {
asciidoc: s.config.editors?.asciidoc?.isActive ?? false,
markdown: s.config.editors?.markdown?.isActive ?? false,
wysiwyg: s.config.editors?.wysiwyg?.isActive ?? false
}
isEnabled: s.isEnabled
}))
}
)
@ -101,12 +122,7 @@ async function routes(app: FastifyInstance) {
...site.config,
id: site.id,
hostname: site.hostname,
isEnabled: site.isEnabled,
editors: {
asciidoc: site.config.editors?.asciidoc?.isActive ?? false,
markdown: site.config.editors?.markdown?.isActive ?? false,
wysiwyg: site.config.editors?.wysiwyg?.isActive ?? false
}
isEnabled: site.isEnabled
}
} else {
return reply.notFound('Site does not exist.')
@ -219,7 +235,29 @@ async function routes(app: FastifyInstance) {
*/
app.put<{
Params: { siteId: string }
Body: { isEnabled?: boolean; hostname?: string; title?: string }
Body: {
isEnabled?: boolean
hostname?: string
title?: string
description?: string
company?: string
contentLicense?: string
footerExtra?: string
pageExtensions?: string[]
pageCasing?: boolean
logoText?: boolean
sitemap?: boolean
discoverable?: boolean
auth?: Record<string, any>
authStrategies?: Array<{ id: string; order?: number; isVisible?: boolean }>
defaults?: Record<string, any>
editors?: Record<string, { isActive?: boolean; config?: Record<string, any> }>
features?: Record<string, any>
locales?: { primary?: string; active?: string[]; forcePrefix?: boolean }
robots?: Record<string, any>
theme?: Record<string, any>
uploads?: Record<string, any>
}
}>(
'/:siteId',
{
@ -229,6 +267,16 @@ async function routes(app: FastifyInstance) {
schema: {
summary: 'Update a site',
tags: ['Sites'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
},
body: {
type: 'object',
properties: {
@ -245,6 +293,64 @@ async function routes(app: FastifyInstance) {
type: 'string',
minLength: 1,
maxLength: 255
},
description: {
type: 'string'
},
company: {
type: 'string'
},
contentLicense: {
type: 'string'
},
footerExtra: {
type: 'string'
},
pageExtensions: {
type: 'array',
items: {
type: 'string',
pattern: '^[a-z0-9]+$'
}
},
pageCasing: {
type: 'boolean'
},
logoText: {
type: 'boolean'
},
sitemap: {
type: 'boolean'
},
discoverable: {
type: 'boolean'
},
auth: {
$ref: 'Site#/properties/auth'
},
authStrategies: {
$ref: 'Site#/properties/authStrategies'
},
defaults: {
$ref: 'Site#/properties/defaults'
},
editors: {
$ref: 'Site#/properties/editors'
},
features: {
$ref: 'Site#/properties/features'
},
locales: {
$ref: 'Site#/properties/locales'
},
robots: {
$ref: 'Site#/properties/robots'
},
theme: {
$ref: 'Site#/properties/theme'
},
uploads: {
$ref: 'Site#/properties/uploads'
}
},
examples: [
@ -253,11 +359,111 @@ async function routes(app: FastifyInstance) {
title: 'My Wiki Site'
}
]
},
response: {
200: {
description: 'Site updated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async () => {
return { hello: 'world' }
async (req, reply) => {
// -> Validate inputs
if (req.body.title !== undefined && !/^[^<>"]+$/.test(req.body.title)) {
throw new CustomError('siteUpdateInvalidTitle', 'Invalid Site Title')
}
const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
if (!site) {
return reply.notFound('Site does not exist.')
}
// -> Check for duplicate hostname
if (
req.body.hostname !== undefined &&
req.body.hostname !== site.hostname &&
!(await WIKI.models.sites.isHostnameUnique(req.body.hostname))
) {
if (req.body.hostname === '*') {
throw new CustomError(
'siteUpdateDuplicateCatchAll',
'A site with a catch-all hostname already exists! Cannot have 2 catch-all hostnames.'
)
} else {
throw new CustomError(
'siteUpdateDuplicateHostname',
'A site with a this hostname already exists! Cannot have duplicate hostnames.'
)
}
}
// -> Validate locales against the installed ones, and against what the site ends up with once
// the patch is merged, so that a partial update cannot leave the primary locale inactive
if (req.body.locales) {
const installedCodes = (await WIKI.models.locales.getLocales()).map((lc: any) => lc.code)
const active = req.body.locales.active ?? site.config.locales?.active ?? []
const primary = req.body.locales.primary ?? site.config.locales?.primary
if (active.length < 1) {
throw new CustomError(
'siteUpdateNoActiveLocale',
'At least one active locale is required.'
)
}
const unknownCodes = [...active, primary].filter(
(code) => code && !installedCodes.includes(code)
)
if (unknownCodes.length > 0) {
throw new CustomError(
'siteUpdateUnknownLocale',
`Locale is not installed: ${[...new Set(unknownCodes)].join(', ')}`
)
}
if (!active.includes(primary)) {
throw new CustomError(
'siteUpdatePrimaryLocaleNotActive',
'The primary locale must be one of the active locales.'
)
}
}
// -> Split the patch between real columns and the config JSONB blob
const config: Record<string, any> = {}
for (const key of SITE_CONFIG_KEYS) {
if (req.body[key] !== undefined) {
config[key] = req.body[key]
}
}
// -> Keep the legacy `features.ratings` flag in sync with the ratings mode
if (config.features?.ratingsMode !== undefined) {
config.features.ratings = config.features.ratingsMode !== 'off'
}
// -> Update site
try {
await WIKI.models.sites.updateSite(req.params.siteId, {
hostname: req.body.hostname,
isEnabled: req.body.isEnabled,
...(Object.keys(config).length < 1 ? {} : { config })
})
return {
ok: true,
message: 'Site updated successfully.'
}
} catch (err: any) {
WIKI.logger.warn(err)
return reply.internalServerError()
}
}
)
@ -300,6 +506,14 @@ async function routes(app: FastifyInstance) {
reply.badRequest('Site does not exist.')
}
} catch (err: any) {
// -> Pages, assets, navigation, tags and the page tree all reference the site without a
// cascade, so a site still holding content cannot be removed. That is a conflict to
// report, not a server fault.
if (err.cause?.code === '23503' || err.code === '23503') {
return reply.conflict(
'Cannot delete a site that still holds content. Delete its pages and assets first.'
)
}
reply.send(err)
}
}

@ -1,10 +1,25 @@
import { CustomError } from '../helpers/common.ts'
import type { FastifyInstance } from 'fastify'
import type { UserPatch } from '../models/users.ts'
interface UserUpdateBody {
name?: string
email?: string
isActive?: boolean
isVerified?: boolean
meta?: Record<string, any>
prefs?: Record<string, any>
groups?: string[]
auth?: Record<string, any>
}
/**
* Users API Routes
*/
async function routes(app: FastifyInstance) {
app.get<{ Querystring: { page?: number; limit?: number } }>(
app.get<{
Querystring: { page?: number; limit?: number; filter?: string; assignableToGroupId?: string }
}>(
'/',
{
config: {
@ -16,6 +31,17 @@ async function routes(app: FastifyInstance) {
querystring: {
type: 'object',
properties: {
filter: {
type: 'string',
description: 'Matched against the user name and email, case-insensitively.',
maxLength: 255
},
assignableToGroupId: {
type: 'string',
format: 'uuid',
description:
'Keep only the users that may be assigned to this group, i.e. omit its current members and any system user. Intended for pickers offering users to assign.'
},
page: { type: 'integer', minimum: 1, default: 1 },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 }
}
@ -37,8 +63,16 @@ async function routes(app: FastifyInstance) {
}
}
},
async () => {
return { hello: 'world' }
async (req) => {
const page = req.query.page ?? 1
const limit = req.query.limit ?? 20
const { total, users } = await WIKI.models.users.getUsers({
filter: req.query.filter ?? '',
assignableToGroupId: req.query.assignableToGroupId ?? '',
page,
limit
})
return { page, limit, total, users }
}
)
@ -66,6 +100,104 @@ async function routes(app: FastifyInstance) {
}
)
/**
* GET USER DEFAULTS
*
* Instance-wide, not per-site: stored as the `userDefaults` key of the settings table.
*/
app.get(
'/defaults',
{
config: {
permissions: ['read:users', 'manage:users']
},
schema: {
summary: 'Get the defaults applied to new users',
tags: ['Users'],
response: {
200: {
description: 'User defaults',
type: 'object',
$ref: 'UserDefaults#'
}
}
}
},
async () => {
return WIKI.config.userDefaults
}
)
/**
* UPDATE USER DEFAULTS
*/
app.put<{ Body: { timezone?: string; dateFormat?: string; timeFormat?: string } }>(
'/defaults',
{
config: {
permissions: ['manage:users']
},
schema: {
summary: 'Update the defaults applied to new users',
description:
'These are instance-wide, not per-site. Existing users keep their own preferences.',
tags: ['Users'],
body: {
$ref: 'UserDefaults#'
},
response: {
200: {
description: 'User defaults updated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
// -> A bad time zone would break every date the affected users see, and the list of valid
// zones is only known at runtime, so it cannot be expressed as a schema enum
if (req.body.timezone !== undefined) {
if (!Intl.supportedValuesOf('timeZone').includes(req.body.timezone)) {
throw new CustomError(
'userDefaultsInvalidTimezone',
`Not a recognized IANA time zone: ${req.body.timezone}`
)
}
}
const patch: Record<string, any> = {}
for (const key of ['timezone', 'dateFormat', 'timeFormat'] as const) {
if (req.body[key] !== undefined) {
patch[key] = req.body[key]
}
}
if (Object.keys(patch).length < 1) {
throw new CustomError('userDefaultsEmpty', 'No user defaults provided to update.')
}
const previousDefaults = WIKI.config.userDefaults
WIKI.config.userDefaults = { ...previousDefaults, ...patch }
if (!(await WIKI.configSvc.saveToDb(['userDefaults']))) {
WIKI.config.userDefaults = previousDefaults
return reply.internalServerError('Failed to save user defaults.')
}
return {
ok: true,
message: 'User defaults updated successfully.'
}
}
)
app.get<{ Params: { userId: string } }>(
'/:userId',
{
@ -74,6 +206,8 @@ async function routes(app: FastifyInstance) {
},
schema: {
summary: 'Get user info',
description:
'Returns the user with its group membership and linked authentication providers.',
tags: ['Users'],
params: {
type: 'object',
@ -82,7 +216,8 @@ async function routes(app: FastifyInstance) {
type: 'string',
format: 'uuid'
}
}
},
required: ['userId']
},
response: {
200: {
@ -93,12 +228,29 @@ async function routes(app: FastifyInstance) {
}
}
},
async () => {
return { hello: 'world' }
async (req, reply) => {
const user = await WIKI.models.users.getUserDetail(req.params.userId)
if (!user) {
return reply.notFound('User does not exist.')
}
return user
}
)
app.post(
/**
* CREATE USER
*/
app.post<{
Body: {
name: string
email: string
password: string
groups?: string[]
mustChangePassword?: boolean
sendWelcomeEmail?: boolean
sendWelcomeEmailFromSiteId?: string
}
}>(
'/',
{
config: {
@ -106,15 +258,116 @@ async function routes(app: FastifyInstance) {
},
schema: {
summary: 'Create a new user',
tags: ['Users']
description:
'Creates a user authenticated against the local strategy. `sendWelcomeEmail` is accepted but not yet supported, as the server has no mail transport.',
tags: ['Users'],
body: {
type: 'object',
required: ['name', 'email', 'password'],
properties: {
name: {
type: 'string',
minLength: 1,
maxLength: 255
},
email: {
type: 'string',
format: 'email',
maxLength: 255
},
password: {
type: 'string',
minLength: 8,
maxLength: 255
},
groups: {
type: 'array',
items: {
type: 'string',
format: 'uuid'
}
},
mustChangePassword: {
type: 'boolean',
default: false
},
sendWelcomeEmail: {
type: 'boolean',
default: false
},
sendWelcomeEmailFromSiteId: {
type: 'string',
format: 'uuid'
}
},
examples: [
{
name: 'Jane Doe',
email: 'jane@example.com',
password: 'a-long-password',
groups: []
}
]
},
response: {
200: {
description: 'User created successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
id: {
type: 'string',
format: 'uuid'
}
}
}
}
}
},
async () => {
return { hello: 'world' }
async (req, reply) => {
if (!/^[^<>"]+$/.test(req.body.name)) {
throw new CustomError('userCreateInvalidName', 'Invalid User Name')
}
if (await WIKI.models.users.getByEmail(req.body.email.toLowerCase())) {
throw new CustomError('userCreateDuplicateEmail', 'A user with this email already exists.')
}
// -> There is no mail transport yet, so accepting this flag would silently drop the request
if (req.body.sendWelcomeEmail) {
throw new CustomError(
'userCreateWelcomeEmailUnavailable',
'Sending a welcome email is not supported yet, as mail delivery is not implemented.'
)
}
try {
const id = await WIKI.models.users.createUser({
name: req.body.name,
email: req.body.email,
password: req.body.password,
groups: req.body.groups ?? [],
mustChangePassword: req.body.mustChangePassword ?? false
})
return {
ok: true,
message: 'User created successfully.',
id
}
} catch (err: any) {
WIKI.logger.warn(err)
return reply.internalServerError()
}
}
)
app.put<{ Params: { userId: string } }>(
/**
* UPDATE USER
*/
app.put<{ Params: { userId: string }; Body: UserUpdateBody }>(
'/:userId',
{
config: {
@ -122,11 +375,237 @@ async function routes(app: FastifyInstance) {
},
schema: {
summary: 'Update a user',
tags: ['Users']
description:
'Updates any subset of the user fields. Omitted fields are left unchanged. Passing `groups` replaces the group membership entirely — except for system users (the guest account), whose membership is fixed.',
tags: ['Users'],
params: {
type: 'object',
properties: {
userId: {
type: 'string',
format: 'uuid'
}
},
required: ['userId']
},
body: {
type: 'object',
properties: {
name: {
type: 'string',
minLength: 1,
maxLength: 255
},
email: {
type: 'string',
format: 'email',
maxLength: 255
},
isActive: {
type: 'boolean'
},
isVerified: {
type: 'boolean'
},
meta: {
type: 'object',
additionalProperties: true
},
prefs: {
type: 'object',
additionalProperties: true
},
groups: {
type: 'array',
items: {
type: 'string',
format: 'uuid'
}
},
auth: {
type: 'object',
description:
'Local-strategy flags: `mustChangePwd`, `restrictLogin`, `tfaRequired`. Secrets cannot be set here — use the password endpoint.',
properties: {
mustChangePwd: {
type: 'boolean'
},
restrictLogin: {
type: 'boolean'
},
tfaRequired: {
type: 'boolean'
}
}
}
}
},
response: {
200: {
description: 'User updated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async () => {
return { hello: 'world' }
async (req, reply) => {
const user = await WIKI.models.users.getById(req.params.userId)
if (!user) {
return reply.notFound('User does not exist.')
}
// -> Collect only the fields actually provided
const patch: UserPatch = {}
for (const key of ['name', 'email', 'isActive', 'isVerified', 'meta', 'prefs'] as const) {
if (req.body[key] !== undefined) {
;(patch as Record<string, any>)[key] = req.body[key]
}
}
if (
Object.keys(patch).length < 1 &&
req.body.groups === undefined &&
req.body.auth === undefined
) {
throw new CustomError('userUpdateEmpty', 'No user fields provided to update.')
}
// -> Email is unique, so a clash needs a clearer answer than a constraint violation
if (patch.email && patch.email.toLowerCase() !== user.email.toLowerCase()) {
if (await WIKI.models.users.getByEmail(patch.email.toLowerCase())) {
throw new CustomError(
'userUpdateDuplicateEmail',
'A user with this email already exists.'
)
}
}
// -> Group membership is replaced wholesale here, which would otherwise be a way around the
// guards on the groups endpoint.
if (req.body.groups !== undefined) {
// -> The guest account must stay in the guests group and nowhere else. Resending the
// membership unchanged is allowed, so that saving another field is not blocked.
if (user.isSystem) {
const current = await WIKI.models.users.getUserGroupIds(req.params.userId)
const requested = req.body.groups
const unchanged =
current.length === requested.length && current.every((id) => requested.includes(id))
if (!unchanged) {
return reply.conflict('Cannot change the group membership of a system user.')
}
}
const rootAdminGroupId = WIKI.config.auth.rootAdminGroupId
const wasRootAdmin = await WIKI.models.groups.isUserInGroup(
rootAdminGroupId,
req.params.userId
)
if (wasRootAdmin && !req.body.groups.includes(rootAdminGroupId)) {
if ((await WIKI.models.groups.countUsersInGroup(rootAdminGroupId)) <= 1) {
return reply.conflict('Cannot remove the last user from the root administrators group.')
}
}
}
try {
if (Object.keys(patch).length > 0) {
await WIKI.models.users.updateUser(req.params.userId, patch)
}
if (req.body.groups !== undefined) {
await WIKI.models.users.setUserGroups(req.params.userId, req.body.groups)
}
if (req.body.auth !== undefined) {
await WIKI.models.users.setUserAuthFlags(req.params.userId, req.body.auth)
}
return {
ok: true,
message: 'User updated successfully.'
}
} catch (err: any) {
WIKI.logger.warn(err)
return reply.internalServerError()
}
}
)
/**
* SET USER PASSWORD
*/
app.put<{
Params: { userId: string }
Body: { newPassword: string; mustChangePassword?: boolean }
}>(
'/:userId/password',
{
config: {
permissions: ['manage:users']
},
schema: {
summary: "Set a user's password",
description: 'Replaces the local-strategy password. Other linked providers are untouched.',
tags: ['Users'],
params: {
type: 'object',
properties: {
userId: {
type: 'string',
format: 'uuid'
}
},
required: ['userId']
},
body: {
type: 'object',
required: ['newPassword'],
properties: {
newPassword: {
type: 'string',
minLength: 8,
maxLength: 255
},
mustChangePassword: {
type: 'boolean',
default: false
}
}
},
response: {
200: {
description: 'Password updated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
const updated = await WIKI.models.users.setUserPassword({
id: req.params.userId,
newPassword: req.body.newPassword,
mustChangePassword: req.body.mustChangePassword ?? false
})
if (!updated) {
return reply.notFound('User does not exist.')
}
return {
ok: true,
message: 'User password updated successfully.'
}
}
)
@ -138,11 +617,57 @@ async function routes(app: FastifyInstance) {
},
schema: {
summary: 'Delete a user',
tags: ['Users']
description:
'System users cannot be deleted, nor can the last user of the root administrators group.',
tags: ['Users'],
params: {
type: 'object',
properties: {
userId: {
type: 'string',
format: 'uuid'
}
},
required: ['userId']
},
response: {
204: {
description: 'User deleted successfully'
}
}
}
},
async () => {
return { hello: 'world' }
async (req, reply) => {
const user = await WIKI.models.users.getById(req.params.userId)
if (!user) {
return reply.notFound('User does not exist.')
}
if (user.isSystem) {
return reply.conflict('Cannot delete a system user.')
}
// -> Emptying the root administrators group would lock everyone out of system management
const rootAdminGroupId = WIKI.config.auth.rootAdminGroupId
if (await WIKI.models.groups.isUserInGroup(rootAdminGroupId, user.id)) {
if ((await WIKI.models.groups.countUsersInGroup(rootAdminGroupId)) <= 1) {
return reply.conflict('Cannot delete the last user of the root administrators group.')
}
}
try {
await WIKI.models.users.deleteUser(user.id)
return reply.code(204).send()
} catch (err: any) {
// -> Pages and assets reference users without a cascade, so a user who authored content
// cannot be removed. That is a conflict to report, not a server fault.
if (err.cause?.code === '23503' || err.code === '23503') {
return reply.conflict(
'Cannot delete a user who still owns pages or assets. Reassign them first.'
)
}
WIKI.logger.warn(err)
return reply.internalServerError()
}
}
)
}

@ -141,6 +141,10 @@ async function postBoot() {
await WIKI.models.locales.reloadCache()
await WIKI.models.sites.reloadCache()
// -> Must follow the sites cache: every site gets a row per installed block
await WIKI.models.blocks.refreshFromDisk()
await WIKI.models.blocks.syncAllSites()
await WIKI.dbManager.subscribeToNotifications()
await WIKI.scheduler.start()
}
@ -174,11 +178,13 @@ async function initHTTPServer() {
// its `Plugin` type is not importable to state this more precisely.)
plugins: [[ajvFormats.default, {}] as any],
onCreate: (ajv: any) => {
// -> Accepts the shorthand, alpha and full forms a color picker can produce:
// #RGB, #RGBA, #RRGGBB and #RRGGBBAA
ajv.addFormat('hexcolor', (data: unknown) => {
// FIXME: pre-existing bug — this is inverted: strings have no `.test()` method, it
// belongs to RegExp. Any value reaching this format validator throws a TypeError.
// Preserved as-is; the fix is `/#[a-fA-F0-9]{6}/.test(data)`.
return typeof data === 'string' && (data as any).test(/#[a-fA-F0-9]{6}/)
return (
typeof data === 'string' &&
/^#(?:[a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/.test(data)
)
})
}
},

@ -112,9 +112,15 @@
"admin.auth.vendor": "Vendor",
"admin.auth.vendorWebsite": "Website",
"admin.blocks.add": "Add Block",
"admin.blocks.addUnavailable": "Adding custom blocks is not implemented yet.",
"admin.blocks.builtin": "Built-in",
"admin.blocks.custom": "Custom",
"admin.blocks.delete": "Delete Block",
"admin.blocks.deleteConfirm": "Are you sure you want to delete the custom block {blockName}?",
"admin.blocks.deleteSuccess": "Block was deleted successfully.",
"admin.blocks.isEnabled": "Enabled",
"admin.blocks.loadFailed": "Failed to load blocks.",
"admin.blocks.saveFailed": "Failed to save the blocks state.",
"admin.blocks.saveSuccess": "Blocks state saved successfully.",
"admin.blocks.subtitle": "Manage dynamic components available for use inside pages.",
"admin.blocks.title": "Content Blocks",
@ -308,8 +314,12 @@
"admin.general.uploads": "Uploads",
"admin.general.urlHandling": "URL Handling",
"admin.groups.assignUser": "Assign User",
"admin.groups.assignUserFailed": "Failed to assign {userName} to this group.",
"admin.groups.assignUserSuccess": "User was assigned to the group successfully. | {count} users were assigned to the group successfully.",
"admin.groups.assignUserTitle": "Assign Users to Group",
"admin.groups.authBehaviors": "Authentication Behaviors",
"admin.groups.create": "New Group",
"admin.groups.createInvalidData": "Cannot create group: invalid data.",
"admin.groups.createSuccess": "Group created successfully.",
"admin.groups.delete": "Delete Group",
"admin.groups.deleteConfirm": "Are you sure you want delete group {groupName}? Any user currently assigned to this group will be unassigned from it.",
@ -330,6 +340,8 @@
"admin.groups.info": "Group Info",
"admin.groups.name": "Group Name",
"admin.groups.nameHint": "Name of the group",
"admin.groups.nameInvalidChars": "Group Name contains invalid characters.",
"admin.groups.nameMissing": "A group name is required.",
"admin.groups.overview": "Overview",
"admin.groups.permissions": "Permissions",
"admin.groups.redirectOnFirstLogin": "First-time Login Redirect",
@ -343,20 +355,26 @@
"admin.groups.ruleDeny": "Deny",
"admin.groups.ruleForceAllow": "Force Allow",
"admin.groups.ruleLocales": "Locale(s)",
"admin.groups.ruleMatch": "Match",
"admin.groups.ruleMatchEnd": "Path Ends With...",
"admin.groups.ruleMatchExact": "Path Is Exactly...",
"admin.groups.ruleMatchRegex": "Path Matches Regex...",
"admin.groups.ruleMatchStart": "Path Starts With...",
"admin.groups.ruleMatchTag": "Has Any Tag...",
"admin.groups.ruleMatchTagAll": "Has All Tags...",
"admin.groups.rulePath": "Path",
"admin.groups.ruleSites": "Site(s)",
"admin.groups.ruleUntitled": "Untitled Rule",
"admin.groups.rules": "Rules",
"admin.groups.rulesNone": "This group doesn't have any rules yet.",
"admin.groups.saveSuccess": "Group saved successfully.",
"admin.groups.selectedLocales": "Any Locale | {n} locale only | {count} locales selected",
"admin.groups.selectedSites": "Any Site | 1 site selected | {count} sites selected",
"admin.groups.subtitle": "Manage user groups and permissions",
"admin.groups.title": "Groups",
"admin.groups.unassignUser": "Unassign User",
"admin.groups.unassignUserConfirm": "Are you sure you want to unassign {userName} from this group?",
"admin.groups.unassignUserSuccess": "User was unassigned from the group successfully.",
"admin.groups.userCount": "User Count",
"admin.groups.users": "Users",
"admin.groups.usersCount": "0 user | 1 user | {count} users",
@ -388,6 +406,7 @@
"admin.locale.downloadTitle": "Download Locale",
"admin.locale.forcePrefix": "Force Locale Prefix",
"admin.locale.forcePrefixHint": "Paths without a locale code will always be redirected to the primary locale.",
"admin.locale.loadFailed": "Failed to fetch locale settings.",
"admin.locale.name": "Name",
"admin.locale.namespaces.hint": "Enables multiple language versions of the same page.",
"admin.locale.namespaces.label": "Multilingual Namespaces",
@ -398,6 +417,7 @@
"admin.locale.primary": "Primary Locale",
"admin.locale.primaryHint": "The locale to use as default / fallback for this site.",
"admin.locale.rtl": "RTL",
"admin.locale.saveSuccess": "Locale settings saved successfully.",
"admin.locale.settings": "Locale Settings",
"admin.locale.sideload": "Sideload Locale Package",
"admin.locale.sideloadHelp": "If you are not connected to the internet or cannot download locale files using the method above, you can instead sideload packages manually by uploading them below.",
@ -407,15 +427,19 @@
"admin.login.background": "Background Image",
"admin.login.backgroundHint": "Specify an image to use as the login background. PNG and JPG are supported, 1920x1080 recommended. Leave empty for default.",
"admin.login.bgUploadSuccess": "Login background image uploaded successfully.",
"admin.login.bgUploadUnavailable": "Uploading a background image is not implemented yet.",
"admin.login.bypassScreen": "Bypass Login Screen",
"admin.login.bypassScreenHint": "Should the user be redirected automatically to the first authentication provider. Has no effect if the first provider is a username/password provider type.",
"admin.login.bypassUnauthorized": "Bypass Unauthorized Screen",
"admin.login.bypassUnauthorizedHint": "Always redirect the user to the login screen instead of showing an unauthorized error page when the user is not logged in.",
"admin.login.experience": "User Experience",
"admin.login.loadFailed": "Failed to load login configuration.",
"admin.login.loginRedirect": "Login Redirect",
"admin.login.loginRedirectHint": "Optionally redirect the user to a specific page when he/she logins (except if first time login which is defined below). This can be overridden at the group level.",
"admin.login.loginRedirectInvalidChars": "Login Redirect contains invalid characters.",
"admin.login.logoutRedirect": "Logout Redirect",
"admin.login.logoutRedirectHint": "Optionally redirect the user to a specific page when he/she logouts. This can be overridden at the group level.",
"admin.login.logoutRedirectInvalidChars": "Logout Redirect contains invalid characters.",
"admin.login.providers": "Login Providers",
"admin.login.providersVisbleWarning": "Note that you can always temporarily show all hidden providers by adding ?all=1 to the url. This is useful to login as local admin while hiding it from normal users.",
"admin.login.saveSuccess": "Login configuration saved successfully.",
@ -423,6 +447,7 @@
"admin.login.title": "Login",
"admin.login.welcomeRedirect": "First-time Login Redirect",
"admin.login.welcomeRedirectHint": "Optionally redirect the user to a specific page when he/she login for the first time. This can be overridden at the group level.",
"admin.login.welcomeRedirectInvalidChars": "First-time Login Redirect contains invalid characters.",
"admin.mail.configuration": "Configuration",
"admin.mail.defaultBaseURL": "Default Base URL",
"admin.mail.dkim": "DKIM (optional)",
@ -437,6 +462,7 @@
"admin.mail.dkimUseHint": "Should DKIM be used when sending emails.",
"admin.mail.saveSuccess": "Configuration saved successfully.",
"admin.mail.sendTestSuccess": "A test email was sent successfully.",
"admin.mail.sendTestUnavailable": "Sending emails is not implemented yet, so no test email can be sent.",
"admin.mail.sender": "Sender",
"admin.mail.senderEmail": "Sender Email",
"admin.mail.senderName": "Sender Name",
@ -634,6 +660,7 @@
"admin.sites.refreshSuccess": "List of sites refreshed successfully.",
"admin.sites.subtitle": "Manage your wiki sites",
"admin.sites.title": "Sites",
"admin.sites.updateSuccess": "Site updated successfully.",
"admin.ssl.currentState": "Current State",
"admin.ssl.domain": "Domain",
"admin.ssl.domainHint": "Enter the fully qualified domain pointing to your wiki. (e.g. wiki.example.com)",
@ -956,6 +983,7 @@
"admin.users.groupAssignNotice": "Note that you cannot assign users to the Administrators or Guests groups from this panel.",
"admin.users.groupSelected": "Assign to {group}",
"admin.users.groups": "Groups",
"admin.users.groupsLoadFailed": "Failed to load groups.",
"admin.users.groupsMissing": "You must assign the user to at least 1 group.",
"admin.users.groupsSelected": "Assign to {count} groups",
"admin.users.id": "ID",
@ -969,6 +997,7 @@
"admin.users.lastUpdated": "Last Updated",
"admin.users.linkedAccounts": "Linked Accounts",
"admin.users.linkedProviders": "Linked Providers",
"admin.users.loadFailed": "Failed to load users.",
"admin.users.loading": "Loading User...",
"admin.users.location": "Location",
"admin.users.locationHint": "The city / country of the user or the office location.",
@ -1009,12 +1038,17 @@
"admin.users.pwdStrengthWeak": "Weak",
"admin.users.refreshSuccess": "Users refreshed successfully.",
"admin.users.saveSuccess": "User saved successfully.",
"admin.users.searchNoResults": "No user matches your search.",
"admin.users.searchUsers": "Search by name or email...",
"admin.users.selectGroup": "Select Group...",
"admin.users.selectUsers": "Select Users",
"admin.users.selectedCount": "{count} selected",
"admin.users.sendWelcomeEmail": "Send Welcome Email",
"admin.users.sendWelcomeEmailAltHint": "An email will be sent to the user with link(s) to the wiki(s) the user has read access to.",
"admin.users.sendWelcomeEmailFromSiteId": "Site to use for the Welcome Email",
"admin.users.sendWelcomeEmailHint": "An email will be sent to the user with his login details.",
"admin.users.subtitle": "Manage Users",
"admin.users.systemUser": "System User",
"admin.users.tfa": "Two Factor Authentication (2FA)",
"admin.users.tfaInvalidate": "Invalidate 2FA",
"admin.users.tfaInvalidateConfirm": "Are you sure you want to invalidate the user current 2FA configuration? This action cannot be undone.",

@ -0,0 +1,191 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { and, eq, inArray } from 'drizzle-orm'
import { blocks as blocksTable, sites as sitesTable } from '../db/schema.ts'
/** A block as declared by its component's `static definition`. */
export interface BlockDefinition {
block: string
name: string
description: string
icon: string
}
/** A block row as exposed by the API. */
export interface SiteBlock {
id: string
block: string
name: string
description: string
icon: string
isEnabled: boolean
isCustom: boolean
config: Record<string, any>
}
const blockSelection = {
id: blocksTable.id,
block: blocksTable.block,
name: blocksTable.name,
description: blocksTable.description,
icon: blocksTable.icon,
isEnabled: blocksTable.isEnabled,
isCustom: blocksTable.isCustom,
config: blocksTable.config
}
/**
* Blocks model
*
* Built-in blocks live in the `blocks/` workspace, one directory per block. Their metadata is
* declared as a `static definition` on each Lit component and collected into
* `blocks/compiled/blocks.manifest.json` by the rollup build, which is what this model reads
* the components themselves cannot be imported outside a browser.
*/
class Blocks {
/** Definitions read from the compiled manifest, refreshed by `refreshFromDisk()`. */
definitions: BlockDefinition[] = []
/**
* Load the built-in block definitions from the compiled manifest.
*
* A missing manifest is not fatal: it just means `blocks` has not been built yet, in which case
* only custom blocks are available.
*/
async refreshFromDisk(): Promise<void> {
const manifestPath = path.join(WIKI.ROOTPATH, 'blocks/compiled/blocks.manifest.json')
try {
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
if (!Array.isArray(manifest)) {
throw new TypeError('Manifest is not an array.')
}
this.definitions = manifest
WIKI.logger.info(`Found ${this.definitions.length} blocks [ OK ]`)
} catch (err: any) {
this.definitions = []
WIKI.logger.warn(
`Could not read the blocks manifest at ${manifestPath} — run "npm run build" in blocks/. [ SKIPPED ]`
)
WIKI.logger.warn(err.message)
}
}
/**
* Register any built-in block missing from a site, and drop rows for built-ins that no longer
* exist on disk. Existing rows are updated in place so that `isEnabled` and `config` survive.
*
* Custom blocks are never touched they have no on-disk counterpart to compare against.
*/
async syncSite(siteId: string): Promise<void> {
const existing = await WIKI.db
.select({ id: blocksTable.id, block: blocksTable.block })
.from(blocksTable)
.where(and(eq(blocksTable.siteId, siteId), eq(blocksTable.isCustom, false)))
const existingKeys = existing.map((b: any) => b.block)
const definedKeys = this.definitions.map((d) => d.block)
for (const definition of this.definitions) {
if (existingKeys.includes(definition.block)) {
// -> Metadata may have changed on disk; state and config belong to the site
await WIKI.db
.update(blocksTable)
.set({
name: definition.name,
description: definition.description,
icon: definition.icon
})
.where(and(eq(blocksTable.siteId, siteId), eq(blocksTable.block, definition.block)))
} else {
await WIKI.db.insert(blocksTable).values({
siteId,
block: definition.block,
name: definition.name,
description: definition.description,
icon: definition.icon,
isEnabled: true,
isCustom: false,
config: {}
})
}
}
// -> A built-in that has been removed from disk should not linger in the admin list
const orphaned = existingKeys.filter((key: string) => !definedKeys.includes(key))
if (orphaned.length > 0) {
await WIKI.db
.delete(blocksTable)
.where(
and(
eq(blocksTable.siteId, siteId),
eq(blocksTable.isCustom, false),
inArray(blocksTable.block, orphaned)
)
)
}
}
/**
* Register the built-in blocks for every site. Called at boot, after the sites cache is loaded.
*/
async syncAllSites(): Promise<void> {
WIKI.logger.info('Registering blocks for all sites...')
const sites = await WIKI.db.select({ id: sitesTable.id }).from(sitesTable)
for (const site of sites) {
await WIKI.models.blocks.syncSite(site.id)
}
WIKI.logger.info(`Registered blocks for ${sites.length} sites [ OK ]`)
}
/**
* Fetch the blocks available to a site, built-in first, then by name
*/
async getSiteBlocks(siteId: string): Promise<SiteBlock[]> {
const results = await WIKI.db
.select(blockSelection)
.from(blocksTable)
.where(eq(blocksTable.siteId, siteId))
.orderBy(blocksTable.isCustom, blocksTable.name)
return results as SiteBlock[]
}
/**
* Enable or disable blocks in bulk.
*
* @param states Block IDs with their desired state
* @returns The number of block rows written a block already in the requested state still counts
*/
async setBlocksState(
siteId: string,
states: { id: string; isEnabled: boolean }[]
): Promise<number> {
let changed = 0
for (const isEnabled of [true, false]) {
const ids = states.filter((s) => s.isEnabled === isEnabled).map((s) => s.id)
if (ids.length < 1) {
continue
}
const result = await WIKI.db
.update(blocksTable)
.set({ isEnabled })
.where(and(eq(blocksTable.siteId, siteId), inArray(blocksTable.id, ids)))
changed += result.rowCount ?? 0
}
return changed
}
/**
* Delete a custom block. Built-in blocks are rejected, since the next sync would recreate them.
*
* @returns Whether a block was deleted
*/
async deleteCustomBlock(siteId: string, id: string): Promise<boolean> {
const result = await WIKI.db
.delete(blocksTable)
.where(
and(eq(blocksTable.siteId, siteId), eq(blocksTable.id, id), eq(blocksTable.isCustom, true))
)
return (result.rowCount ?? 0) > 0
}
}
export const blocks = new Blocks()

@ -148,6 +148,38 @@ class Groups {
])
}
/**
* Create a new (non-system) group, seeded with the same starting permissions and default rule as
* the `Users` group.
*
* @param name Group name
* @returns The new group's ID
*/
async createGroup(name: string): Promise<string> {
const startingPermissions = ['read:pages', 'read:assets', 'read:comments']
const result = await WIKI.db
.insert(groupsTable)
.values({
name,
permissions: startingPermissions,
rules: [
{
id: uuid(),
name: 'Default Rule',
roles: startingPermissions,
match: 'START',
mode: 'ALLOW',
path: '',
locales: [],
sites: []
}
],
isSystem: false
})
.returning({ id: groupsTable.id })
return result[0].id
}
/**
* Fetch all groups, ordered by name
*/

@ -1,4 +1,5 @@
import { authentication } from './authentication.ts'
import { blocks } from './blocks.ts'
import { groups } from './groups.ts'
import { jobs } from './jobs.ts'
import { locales } from './locales.ts'
@ -9,6 +10,7 @@ import { users } from './users.ts'
export default {
authentication,
blocks,
groups,
jobs,
locales,

@ -1,6 +1,6 @@
import { toMerged } from 'es-toolkit/object'
import { mergeWith, toMerged } from 'es-toolkit/object'
import { keyBy } from 'es-toolkit/array'
import { sites as sitesTable } from '../db/schema.ts'
import { blocks as blocksTable, sites as sitesTable } from '../db/schema.ts'
import { eq } from 'drizzle-orm'
import type { SystemIds } from './types.ts'
@ -84,6 +84,7 @@ class Sites {
comments: false,
contributions: false,
profile: true,
reasonForChange: 'required',
search: true
},
logoUrl: '',
@ -93,9 +94,20 @@ class Sites {
index: true,
follow: true
},
// -> Local authentication is the only strategy guaranteed to exist at this point
authStrategies: [{ id: WIKI.data.systemIds.localAuthId, order: 0, isVisible: true }],
auth: {
autoLogin: false,
bypassUnauthorized: false,
hideLocal: false,
loginRedirect: '/',
welcomeRedirect: '/',
logoutRedirect: '/'
},
locales: {
primary: 'en',
active: ['en']
active: ['en'],
forcePrefix: false
},
assets: {
logo: false,
@ -190,20 +202,71 @@ class Sites {
// }
// })
// -> Site lookups by id / hostname are served from cache, which must know about the new site
await WIKI.models.sites.reloadCache()
// -> Otherwise the new site would have no blocks until the next restart
await WIKI.models.blocks.syncSite(newSite.id)
return newSite
}
async updateSite(id: string, patch: Record<string, any>) {
// FIXME: pre-existing bug — `WIKI.db.sites.query()` is leftover Objection.js API that does not
// exist on a Drizzle instance, so this method always throws. Needs rewriting as a Drizzle
// `update(sitesTable).set(patch).where(eq(sitesTable.id, id))`.
return (WIKI.db as any).sites.query().findById(id).patch(patch)
async updateSite(
id: string,
patch: { hostname?: string; isEnabled?: boolean; config?: Record<string, any> }
): Promise<boolean> {
const values: Partial<typeof sitesTable.$inferInsert> = {}
if (patch.hostname !== undefined) {
values.hostname = patch.hostname
}
if (patch.isEnabled !== undefined) {
values.isEnabled = patch.isEnabled
}
if (patch.config) {
// -> Config is a JSONB blob, so it must be read and merged rather than partially assigned.
// Arrays are replaced rather than merged index-wise, otherwise removing an entry (e.g. a page
// extension) would leave the original value in place.
const current = await WIKI.db
.select({ config: sitesTable.config })
.from(sitesTable)
.where(eq(sitesTable.id, id))
if (current.length < 1) {
return false
}
values.config = mergeWith(
current[0].config as Record<string, any>,
patch.config,
(_targetValue, sourceValue) => (Array.isArray(sourceValue) ? sourceValue : undefined)
)
}
if (Object.keys(values).length < 1) {
return false
}
const updatedResult = await WIKI.db.update(sitesTable).set(values).where(eq(sitesTable.id, id))
if ((updatedResult.rowCount ?? 0) < 1) {
return false
}
await WIKI.models.sites.reloadCache()
return true
}
async deleteSite(id: string): Promise<boolean> {
// await WIKI.db.storage.query().delete().where('siteId', id)
// -> Block rows are registration metadata derived from disk, and their FK has no cascade, so
// they would otherwise block the delete. Content tables (pages, assets, ...) deliberately
// still do — see the conflict handling in the route.
await WIKI.db.delete(blocksTable).where(eq(blocksTable.siteId, id))
const deletedResult = await WIKI.db.delete(sitesTable).where(eq(sitesTable.id, id))
return Boolean((deletedResult.rowCount ?? 0) > 0)
if ((deletedResult.rowCount ?? 0) < 1) {
return false
}
await WIKI.models.sites.reloadCache()
return true
}
async countSites() {
@ -249,9 +312,18 @@ class Sites {
follow: true
},
authStrategies: [{ id: ids.authModuleId, order: 0, isVisible: true }],
auth: {
autoLogin: false,
bypassUnauthorized: false,
hideLocal: false,
loginRedirect: '/',
welcomeRedirect: '/',
logoutRedirect: '/'
},
locales: {
primary: 'en',
active: ['en']
active: ['en'],
forcePrefix: false
},
assets: {
logo: false,

@ -1,10 +1,83 @@
import bcrypt from 'bcryptjs'
import { userGroups, users as usersTable, userKeys } from '../db/schema.ts'
import { eq } from 'drizzle-orm'
import {
authentication as authenticationTable,
groups as groupsTable,
sessions as sessionsTable,
userGroups,
users as usersTable,
userKeys
} from '../db/schema.ts'
import { and, count, eq, ilike, inArray, notExists, or, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { flatten, uniq } from 'es-toolkit/array'
import type { SystemIds } from './types.ts'
/** The essential user fields, mirroring the `UserCore` API schema. */
export interface UserCore {
id: string
name: string
email: string
hasAvatar: boolean
isSystem: boolean
isActive: boolean
isVerified: boolean
createdAt: Date
updatedAt: Date
lastLoginAt: Date | null
}
/** One page of users, with the total matching the filter rather than the page size. */
export interface UserPage {
total: number
users: UserCore[]
}
/**
* An authentication provider linked to a user, as exposed by the API. Secrets held in the stored
* `auth` blob (the password hash, the TFA secret) are never included `isPasswordSet` and
* `tfaIsActive` report their state instead.
*/
export interface UserAuthProvider {
authId: string
authName: string
strategyKey: string
strategyIcon: string
config: Record<string, any>
}
/** The subset of user fields that may be modified. `isSystem` is deliberately absent. */
export interface UserPatch {
name?: string
email?: string
isActive?: boolean
isVerified?: boolean
meta?: Record<string, any>
prefs?: Record<string, any>
}
/**
* Escape the LIKE wildcards `%` and `_` (and the escape character itself) so that a user-supplied
* filter is matched literally. Values are still parameterized by the driver this is about a `%`
* in the filter silently matching everything, not about injection.
*/
function escapeLikePattern(value: string): string {
return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')
}
/** Selection shared by the list / detail queries. Never includes `auth` or `passkeys`. */
const userSelection = {
id: usersTable.id,
name: usersTable.name,
email: usersTable.email,
hasAvatar: usersTable.hasAvatar,
isSystem: usersTable.isSystem,
isActive: usersTable.isActive,
isVerified: usersTable.isVerified,
createdAt: usersTable.createdAt,
updatedAt: usersTable.updatedAt,
lastLoginAt: usersTable.lastLoginAt
}
export interface LoginOptions {
siteId: string
strategyId: string
@ -35,6 +108,326 @@ class Users {
return res?.[0] ?? null
}
/**
* Fetch a page of users, optionally filtered by name or email
*
* @param filter Matched literally against name and email, case-insensitively
* @param assignableToGroupId Keep only the users that may be assigned to this group
* @returns The page of users plus the total number matching the filter
*/
async getUsers({
filter = '',
assignableToGroupId = '',
page = 1,
limit = 20
}: {
filter?: string
assignableToGroupId?: string
page?: number
limit?: number
} = {}): Promise<UserPage> {
const conditions = []
if (filter) {
const pattern = `%${escapeLikePattern(filter)}%`
conditions.push(or(ilike(usersTable.name, pattern), ilike(usersTable.email, pattern))!)
}
if (assignableToGroupId) {
// -> Members of the group have nothing left to assign, and system users (the guest account)
// have a fixed membership that `POST /groups/:id/users/:id` refuses to change
conditions.push(eq(usersTable.isSystem, false))
conditions.push(
notExists(
WIKI.db
.select({ exists: sql`1` })
.from(userGroups)
.where(
and(eq(userGroups.userId, usersTable.id), eq(userGroups.groupId, assignableToGroupId))
)
)
)
}
const where = conditions.length > 0 ? and(...conditions) : undefined
const totals = await WIKI.db.select({ total: count() }).from(usersTable).where(where)
const users = await WIKI.db
.select(userSelection)
.from(usersTable)
.where(where)
.orderBy(usersTable.name)
.limit(limit)
.offset((page - 1) * limit)
return {
total: totals[0]?.total ?? 0,
users
}
}
/**
* Fetch a single user with the groups it belongs to and the authentication providers linked to it.
*
* The stored `auth` blob is keyed by strategy ID and holds secrets, so it is reshaped into a list
* of providers carrying only state (`isPasswordSet`, `tfaIsActive`) never the password hash or
* the TFA secret.
*
* @param id User ID
* @returns The user, or null if no such user exists
*/
async getUserDetail(id: string) {
const results = await WIKI.db.select().from(usersTable).where(eq(usersTable.id, id)).limit(1)
const user = results[0]
if (!user) {
return null
}
const groups = await WIKI.db
.select({ id: groupsTable.id, name: groupsTable.name })
.from(userGroups)
.innerJoin(groupsTable, eq(groupsTable.id, userGroups.groupId))
.where(eq(userGroups.userId, id))
.orderBy(groupsTable.name)
const strategies = await WIKI.db.select().from(authenticationTable)
const auth: UserAuthProvider[] = []
for (const [strategyId, rawConfig] of Object.entries(
(user.auth ?? {}) as Record<string, any>
)) {
const strategy = strategies.find((s: any) => s.id === strategyId)
const definition = WIKI.data.authentication?.find((d: any) => d.key === strategy?.module)
const { password, tfaSecret, ...config } = rawConfig ?? {}
auth.push({
authId: strategyId,
authName: strategy?.displayName || definition?.title || strategy?.module || 'Unknown',
strategyKey: strategy?.module ?? 'unknown',
strategyIcon: definition?.icon ?? '',
config: {
...config,
isPasswordSet: Boolean(password),
tfaIsActive: Boolean(tfaSecret)
}
})
}
return {
id: user.id,
name: user.name,
email: user.email,
hasAvatar: user.hasAvatar,
isSystem: user.isSystem,
isActive: user.isActive,
isVerified: user.isVerified,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
lastLoginAt: user.lastLoginAt,
meta: user.meta,
prefs: user.prefs,
auth,
groups
}
}
/**
* Create a new user, authenticated against the local strategy.
*
* @returns The new user's ID
*/
async createUser({
name,
email,
password,
groups = [],
mustChangePassword = false,
isVerified = true
}: {
name: string
email: string
password: string
groups?: string[]
mustChangePassword?: boolean
/**
* Defaults to true: an administrator creating the account vouches for the address, and login
* rejects unverified users with `ERR_USER_NOT_VERIFIED` which no email can currently clear.
*/
isVerified?: boolean
}): Promise<string> {
const localStrategyId = WIKI.data.systemIds.localAuthId
const result = await WIKI.db
.insert(usersTable)
.values({
email: email.toLowerCase(),
name,
auth: {
[localStrategyId]: {
password: await bcrypt.hash(password, 12),
mustChangePwd: mustChangePassword,
restrictLogin: false,
tfaIsActive: false,
tfaRequired: false,
tfaSecret: ''
}
},
isSystem: false,
isActive: true,
isVerified,
meta: {
location: '',
jobTitle: '',
pronouns: ''
},
prefs: {
// -> Seeded from the instance-wide user defaults, which an administrator can change
timezone: WIKI.config.userDefaults?.timezone ?? 'America/New_York',
dateFormat: WIKI.config.userDefaults?.dateFormat ?? 'YYYY-MM-DD',
timeFormat: WIKI.config.userDefaults?.timeFormat ?? '12h',
appearance: 'site',
cvd: 'none'
}
})
.returning({ id: usersTable.id })
const userId = result[0].id
if (groups.length > 0) {
await this.setUserGroups(userId, groups)
}
return userId
}
/**
* Update a user's own fields. Group membership is handled by `setUserGroups()`.
*
* @param patch Fields to change must not be empty
* @returns Whether a user was updated
*/
async updateUser(id: string, patch: UserPatch): Promise<boolean> {
const values: Record<string, any> = { ...patch, updatedAt: sql`now()` }
if (typeof values.email === 'string') {
values.email = values.email.toLowerCase()
}
const result = await WIKI.db.update(usersTable).set(values).where(eq(usersTable.id, id))
return (result.rowCount ?? 0) > 0
}
/**
* The IDs of the groups a user belongs to
*/
async getUserGroupIds(userId: string): Promise<string[]> {
const rows = await WIKI.db
.select({ groupId: userGroups.groupId })
.from(userGroups)
.where(eq(userGroups.userId, userId))
return rows.map((r: any) => r.groupId)
}
/**
* Replace a user's group membership with exactly the given groups.
*
* Unknown group IDs are ignored rather than failing the whole update, so that a stale client does
* not block an otherwise valid save.
*/
async setUserGroups(userId: string, groupIds: string[]): Promise<void> {
const wanted =
groupIds.length > 0
? await WIKI.db
.select({ id: groupsTable.id })
.from(groupsTable)
.where(inArray(groupsTable.id, groupIds))
: []
const wantedIds = wanted.map((g: any) => g.id)
await WIKI.db.delete(userGroups).where(eq(userGroups.userId, userId))
if (wantedIds.length > 0) {
await WIKI.db
.insert(userGroups)
.values(wantedIds.map((groupId: string) => ({ userId, groupId })))
}
}
/**
* Update the local-strategy behaviour flags for a user, leaving secrets and any other linked
* provider untouched.
*
* @param flags Any of `mustChangePwd`, `restrictLogin`, `tfaRequired`
* @returns False if the user does not exist
*/
async setUserAuthFlags(id: string, flags: Record<string, any>): Promise<boolean> {
const user = await this.getById(id)
if (!user) {
return false
}
const localStrategyId = WIKI.data.systemIds.localAuthId
const auth = (user.auth ?? {}) as Record<string, any>
const current = auth[localStrategyId]
if (!current) {
// -> The user does not use local authentication, so there are no local flags to set
return false
}
for (const key of ['mustChangePwd', 'restrictLogin', 'tfaRequired'] as const) {
if (flags[key] !== undefined) {
current[key] = Boolean(flags[key])
}
}
auth[localStrategyId] = current
await WIKI.db
.update(usersTable)
.set({ auth, updatedAt: sql`now()` })
.where(eq(usersTable.id, id))
return true
}
/**
* Set a user's local-strategy password, leaving any other linked provider untouched.
*
* @returns False if the user does not exist
*/
async setUserPassword({
id,
newPassword,
mustChangePassword = false
}: {
id: string
newPassword: string
mustChangePassword?: boolean
}): Promise<boolean> {
const user = await this.getById(id)
if (!user) {
return false
}
const localStrategyId = WIKI.data.systemIds.localAuthId
const auth = (user.auth ?? {}) as Record<string, any>
auth[localStrategyId] = {
...auth[localStrategyId],
password: await bcrypt.hash(newPassword, 12),
mustChangePwd: mustChangePassword
}
await WIKI.db
.update(usersTable)
.set({ auth, updatedAt: sql`now()` })
.where(eq(usersTable.id, id))
return true
}
/**
* Delete a user.
*
* Group assignments cascade, but sessions and keys do not they are login artifacts, so they are
* cleared here rather than blocking the delete. References from authored content (pages, assets)
* have no cascade either and will make this throw, which is deliberate: the delete is refused
* rather than silently orphaning content.
*
* @returns Whether a user was deleted
*/
async deleteUser(id: string): Promise<boolean> {
await WIKI.db.delete(userKeys).where(eq(userKeys.userId, id))
await WIKI.db.delete(sessionsTable).where(eq(sessionsTable.userId, id))
const result = await WIKI.db.delete(usersTable).where(eq(usersTable.id, id))
return (result.rowCount ?? 0) > 0
}
async init(ids: SystemIds): Promise<void> {
WIKI.logger.info('Inserting default users...')

@ -5,6 +5,17 @@ import treeQuery from './tree.graphql'
* Block Index
*/
export class BlockIndexElement extends LitElement {
/**
* Metadata for the admin area. Collected at build time into `compiled/blocks.manifest.json`,
* which the server reads to register the block. Values must be plain literals.
*/
static definition = {
block: 'index',
name: 'Index',
description: 'Displays a list of pages contained in a folder.',
icon: 'index'
}
static get styles() {
return css`
:host {

@ -4,6 +4,17 @@ import { LitElement, html, css } from 'lit'
* Block Media Player
*/
export class BlockMediaPlayerElement extends LitElement {
/**
* Metadata for the admin area. Collected at build time into `compiled/blocks.manifest.json`,
* which the server reads to register the block. Values must be plain literals.
*/
static definition = {
block: 'media-player',
name: 'Media Player',
description: 'Plays an audio or video file inline.',
icon: 'widescreen'
}
static get styles() {
return css`
:host {

@ -5,6 +5,74 @@ import graphql from '@rollup/plugin-graphql'
import * as glob from 'glob'
/**
* Turn an ESTree literal node into a plain JS value.
*
* Only literals, arrays and objects of literals are supported a block definition is metadata, so
* anything computed is a mistake worth failing the build over.
*/
function literalToValue (node, blockDir) {
switch (node.type) {
case 'Literal':
return node.value
case 'ArrayExpression':
return node.elements.map(el => literalToValue(el, blockDir))
case 'ObjectExpression':
return Object.fromEntries(node.properties.map(prop => [
prop.key.name ?? prop.key.value,
literalToValue(prop.value, blockDir)
]))
default:
throw new Error(`${blockDir}: "static definition" must contain only plain literals, got ${node.type}.`)
}
}
/**
* Collects each block's `static definition` into `compiled/blocks.manifest.json`.
*
* The definitions are read from the AST rather than by importing the modules, since a component
* registers itself with `customElements` on load and so cannot be imported outside a browser.
*/
function blocksManifest () {
const definitions = new Map()
return {
name: 'blocks-manifest',
buildStart () {
definitions.clear()
},
transform (code, id) {
if (!id.endsWith('/component.js')) {
return null
}
const blockDir = id.split('/').at(-2)
const ast = this.parse(code)
for (const node of ast.body) {
const classNode = node.type === 'ExportNamedDeclaration' ? node.declaration : node
if (classNode?.type !== 'ClassDeclaration') {
continue
}
const definitionNode = classNode.body.body.find(member =>
member.type === 'PropertyDefinition' && member.static && member.key.name === 'definition'
)
if (definitionNode) {
definitions.set(blockDir, literalToValue(definitionNode.value, blockDir))
}
}
if (!definitions.has(blockDir)) {
this.warn(`${blockDir} has no "static definition" — it will not appear in the admin area.`)
}
return null
},
generateBundle () {
this.emitFile({
type: 'asset',
fileName: 'blocks.manifest.json',
source: JSON.stringify([...definitions.values()], null, 2) + '\n'
})
}
}
}
export default {
input: Object.fromEntries(
glob.sync('@(block-*)/component.js', {
@ -28,6 +96,7 @@ export default {
}
},
plugins: [
blocksManifest(),
resolve(),
graphql(),
terser({

@ -13,7 +13,8 @@
"process": "readonly",
"chrome": "readonly",
"API_CLIENT": "readonly",
"EVENT_BUS": "readonly"
"EVENT_BUS": "readonly",
"Temporal": "readonly"
},
"ignorePatterns": [
"dist/**",

@ -1,14 +0,0 @@
{
"recommendations": [
"editorconfig.editorconfig",
"johnsoncodehk.volar",
"wayou.vscode-todo-highlight"
],
"unwantedRecommendations": [
"octref.vetur",
"hookyqr.beautify",
"dbaeumer.jshint",
"ms-vscode.vscode-typescript-tslint-plugin",
"dbaeumer.vscode-eslint"
]
}

File diff suppressed because it is too large Load Diff

@ -24,6 +24,7 @@
"codemirror": "5.65.11",
"codemirror-asciidoc": "1.0.4",
"dependency-graph": "1.0.0",
"es-toolkit": "1.50.0",
"filesize": "11.0.17",
"filesize-parser": "1.5.1",
"fuse.js": "7.4.2",
@ -68,6 +69,7 @@
"sortablejs": "1.15.7",
"sortablejs-vue3": "1.3.0",
"tabulator-tables": "6.4.0",
"temporal-polyfill": "1.0.1",
"tippy.js": "6.3.7",
"twemoji": "14.0.2",
"typescript": "6.0.3",
@ -85,6 +87,7 @@
"@quasar/app-vite": "2.6.2",
"@quasar/vite-plugin": "1.12.0",
"@types/lodash": "4.17.24",
"@vitejs/plugin-vue": "6.0.8",
"@vue/language-plugin-pug": "3.3.5",
"autoprefixer": "10.5.0",
"browserlist": "latest",
@ -92,6 +95,7 @@
"oxfmt": "0.54.0",
"oxlint": "1.69.0",
"sass": "1.101.0",
"vite": "8.1.5",
"vite-plugin-vue-devtools": "8.1.2"
},
"engines": {

@ -0,0 +1,14 @@
/**
* Installs a `Temporal` polyfill on browsers that don't implement it natively yet (Safari, as of
* mid-2026). The import is dynamic and guarded, so browsers with native support never download it.
*
* Must run before anything that touches `Temporal` it is awaited first in `main.js`.
*/
export async function initializeTemporal () {
if (typeof globalThis.Temporal !== 'undefined') {
return
}
// -> Patches globalThis.Temporal, Intl.DateTimeFormat and Date.prototype.toTemporalInstant
await import('temporal-polyfill/global')
}

@ -257,7 +257,7 @@ import { useI18n } from 'vue-i18n'
import { useQuasar } from 'quasar'
import { onMounted, reactive } from 'vue'
import { cloneDeep } from 'lodash-es'
import { toMerged } from 'es-toolkit/object'
import { useAdminStore } from '@/stores/admin'
import { useEditorStore } from '@/stores/editor'
@ -279,22 +279,30 @@ const { t } = useI18n()
// DATA
const state = reactive({
config: {
allowHTML: false,
linkify: false,
lineBreaks: false,
/**
* Fallbacks for options a site may not have stored yet, so that every control renders with a
* defined value. Must mirror the markdown defaults used by the backend when creating a site.
*/
function defaultConfig () {
return {
allowHTML: true,
linkify: true,
lineBreaks: true,
typographer: false,
quotes: 'english',
underline: false,
underline: true,
tabWidth: 2,
latexEngine: 'katex',
multimdTable: false,
multimdTable: true,
plantuml: false,
plantumlServerUrl: 'https://',
plantumlServerUrl: 'https://www.plantuml.com/plantuml/',
kroki: false,
krokiServerUrl: 'https://'
},
krokiServerUrl: 'https://kroki.io'
}
}
const state = reactive({
config: defaultConfig(),
loading: 0
})
@ -328,28 +336,11 @@ async function load () {
state.loading++
$q.loading.show()
try {
const resp = await APOLLO_CLIENT.query({
query: `
query getEditorsState (
$siteId: UUID!
) {
siteById (
id: $siteId
) {
id
editors {
markdown {
config
}
}
}
}`,
variables: {
siteId: adminStore.currentSiteId
},
fetchPolicy: 'network-only'
})
state.config = cloneDeep(resp?.data?.siteById?.editors?.markdown?.config)
const resp = await API_CLIENT.get(`sites/${adminStore.currentSiteId}?strict=true`).json()
if (!resp?.editors?.markdown?.config) {
throw new Error('Failed to fetch markdown editor configuration.')
}
state.config = toMerged(defaultConfig(), resp.editors.markdown.config)
} catch (err) {
$q.notify({
type: 'negative',
@ -363,43 +354,23 @@ async function load () {
async function save () {
state.loading++
try {
const respRaw = await APOLLO_CLIENT.mutate({
mutation: `
mutation saveEditorState (
$id: UUID!
$patch: SiteUpdateInput!
) {
updateSite (
id: $id,
patch: $patch
) {
operation {
succeeded
slug
message
}
}
}
`,
variables: {
id: adminStore.currentSiteId,
patch: {
editors: {
markdown: { config: state.config }
}
// -> Only `config` is sent, so the editor's active state is left untouched by the merge
const resp = await API_CLIENT.put(`sites/${adminStore.currentSiteId}`, {
json: {
editors: {
markdown: { config: state.config }
}
}
})
if (respRaw?.data?.updateSite?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('admin.editors.markdown.saveSuccess')
})
editorStore.$patch({ configIsLoaded: false })
close()
} else {
throw new Error(respRaw?.data?.updateSite?.operation?.message || 'An unexpected error occured.')
}).json()
if (!resp?.ok) {
throw new Error(t(`admin.editors.markdown.${resp?.error}`, resp?.message || 'An unexpected error occured.'))
}
$q.notify({
type: 'positive',
message: t('admin.editors.markdown.saveSuccess')
})
editorStore.$patch({ configIsLoaded: false })
close()
} catch (err) {
$q.notify({
type: 'negative',

@ -86,34 +86,19 @@ async function create () {
if (!isFormValid) {
throw new Error(t('admin.groups.createInvalidData'))
}
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation createGroup (
$name: String!
) {
createGroup(
name: $name
) {
operation {
succeeded
message
}
}
}
`,
variables: {
const resp = await API_CLIENT.post('groups', {
json: {
name: state.groupName
}
})
if (resp?.data?.createGroup?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('admin.groups.createSuccess')
})
onDialogOK()
} else {
throw new Error(resp?.data?.createGroup?.operation?.message || 'An unexpected error occured.')
}).json()
if (!resp?.ok) {
throw new Error(t(`admin.groups.${resp?.error}`, resp?.message || 'An unexpected error occured.'))
}
$q.notify({
type: 'positive',
message: t('admin.groups.createSuccess')
})
onDialogOK()
} catch (err) {
$q.notify({
type: 'negative',

@ -62,34 +62,22 @@ const { t } = useI18n()
async function confirm () {
try {
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation deleteGroup ($id: UUID!) {
deleteGroup(id: $id) {
operation {
succeeded
message
}
}
}
`,
variables: {
id: props.group.id
}
})
if (resp?.data?.deleteGroup?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('admin.groups.deleteSuccess')
})
onDialogOK()
} else {
throw new Error(resp?.data?.deleteGroup?.operation?.message || 'An unexpected error occured.')
const resp = await API_CLIENT.delete(`groups/${props.group.id}`)
if (!resp?.ok) {
throw new Error((await resp.json())?.message || 'An unexpected error occured.')
}
$q.notify({
type: 'positive',
message: t('admin.groups.deleteSuccess')
})
onDialogOK()
} catch (err) {
// -> ky throws for statuses above 400 (e.g. 409 for a system group), where the reason the API
// gave is in the response body rather than in the error message
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({
type: 'negative',
message: err.message
message: apiMessage || err.message
})
}
}

@ -30,6 +30,8 @@ q-layout(view='hHh lpR fFf', container)
text-color='white'
:label='t(`common.actions.save`)'
icon='las la-check'
:loading='state.isLoading'
@click='save'
)
q-drawer.bg-dark-6(:model-value='true', :width='250', dark)
q-list(padding, v-show='!state.isLoading')
@ -483,13 +485,17 @@ q-layout(view='hHh lpR fFf', container)
:label='t(`common.actions.edit`)'
no-caps
)
//- Hidden for system users: the guest account's membership is fixed, and the API
//- refuses to change it either way
q-btn.acrylic-btn(
v-if='!props.row.isSystem'
flat
icon='las la-user-minus'
color='accent'
:aria-label='t(`admin.groups.unassignUser`)'
@click='unassignUser(props.row)'
)
q-tooltip(anchor='center left' self='center right') {{ t('admin.groups.unassignUser') }}
.flex.flex-center.q-mt-md(v-if='usersTotalPages > 1')
q-pagination(
@ -503,9 +509,7 @@ q-layout(view='hHh lpR fFf', container)
<script setup>
import { DateTime } from 'luxon'
import { v4 as uuid } from 'uuid'
import { cloneDeep, some } from 'lodash-es'
import { fileOpen } from 'browser-fs-access'
import { useI18n } from 'vue-i18n'
@ -516,6 +520,8 @@ import { useRouter, useRoute } from 'vue-router'
import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site'
import UserSearchDialog from '@/components/UserSearchDialog.vue'
// QUASAR
const $q = useQuasar()
@ -807,7 +813,14 @@ function checkRoute () {
function humanizeDate (val) {
if (!val) { return '---' }
return DateTime.fromISO(val).toLocaleString(DateTime.DATETIME_FULL)
return Temporal.Instant.from(val).toLocaleString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short'
})
}
function getRuleModeColor (mode) {
@ -862,48 +875,41 @@ function refresh () {
async function fetchGroup () {
state.isLoading = true
try {
const resp = await APOLLO_CLIENT.query({
query: `
query adminFetchGroup (
$id: UUID!
) {
groupById(
id: $id
) {
id
name
redirectOnLogin
redirectOnFirstLogin
redirectOnLogout
isSystem
permissions
rules {
id
name
path
roles
match
mode
locales
sites
}
userCount
createdAt
updatedAt
}
}
`,
variables: {
id: adminStore.overlayOpts.id
},
fetchPolicy: 'network-only'
})
if (resp?.data?.groupById) {
state.group = cloneDeep(resp.data.groupById)
state.usersTotal = state.group.userCount ?? 0
} else {
const resp = await API_CLIENT.get(`groups/${adminStore.overlayOpts.id}`).json()
if (!resp?.id) {
throw new Error('An unexpected error occured while fetching group details.')
}
state.group = resp
state.usersTotal = state.group.userCount ?? 0
} catch (err) {
$q.notify({
type: 'negative',
message: err.message
})
}
state.isLoading = false
}
async function save () {
state.isLoading = true
try {
const resp = await API_CLIENT.put(`groups/${state.group.id}`, {
json: {
name: state.group.name,
redirectOnLogin: state.group.redirectOnLogin ?? '',
redirectOnFirstLogin: state.group.redirectOnFirstLogin ?? '',
redirectOnLogout: state.group.redirectOnLogout ?? '',
permissions: state.group.permissions ?? [],
rules: state.group.rules ?? []
}
}).json()
if (!resp?.ok) {
throw new Error(t(`admin.groups.${resp?.error}`, resp?.message || 'An unexpected error occured.'))
}
$q.notify({
type: 'positive',
message: t('admin.groups.saveSuccess')
})
} catch (err) {
$q.notify({
type: 'negative',
@ -979,8 +985,8 @@ async function importRules () {
match: ['START', 'END', 'REGEX', 'TAG', 'TAGALL', 'EXACT'].includes(r.match) ? r.match : 'START',
roles: r.roles || [],
path: r.path || '',
locales: r.locales.filter(l => some(adminStore.locales, ['code', l])),
sites: r.sites.filter(s => some(adminStore.sites, ['id', s]))
locales: r.locales.filter(l => adminStore.locales.some(loc => loc.code === l)),
sites: r.sites.filter(s => adminStore.sites.some(site => site.id === s))
}))
]
$q.notify({
@ -999,49 +1005,18 @@ async function importRules () {
async function refreshUsers () {
state.isLoadingUsers = true
try {
const resp = await APOLLO_CLIENT.query({
query: `
query adminFetchGroupUsers (
$filter: String
$page: Int
$pageSize: Int
$groupId: UUID!
) {
groupById (
id: $groupId
) {
id
userCount
users (
filter: $filter
page: $page
pageSize: $pageSize
) {
id
name
email
isSystem
isActive
createdAt
lastLoginAt
}
}
}
`,
variables: {
filter: state.usersFilter,
const resp = await API_CLIENT.get(`groups/${adminStore.overlayOpts.id}/users`, {
searchParams: {
...(state.usersFilter ? { filter: state.usersFilter } : {}),
page: state.usersPage,
pageSize: state.usersPageSize,
groupId: adminStore.overlayOpts.id
},
fetchPolicy: 'network-only'
})
if (resp?.data?.groupById?.users) {
state.usersTotal = resp.data.groupById.userCount ?? 0
state.users = cloneDeep(resp.data.groupById.users)
} else {
limit: state.usersPageSize
}
}).json()
if (!Array.isArray(resp?.users)) {
throw new Error('An unexpected error occured while fetching group users.')
}
state.usersTotal = resp.total ?? 0
state.users = resp.users
} catch (err) {
$q.notify({
type: 'negative',
@ -1052,11 +1027,73 @@ async function refreshUsers () {
}
function assignUser () {
$q.dialog({
component: UserSearchDialog,
componentProps: {
title: t('admin.groups.assignUserTitle'),
// -> Only offer users the API would actually accept: not already members, not system users
assignableToGroupId: state.group.id
}
}).onOk(async (users) => {
state.isLoadingUsers = true
// -> Assignment is one user per request, so a failure partway through still leaves the
// successful ones assigned; report both sides rather than a single all-or-nothing message.
let assigned = 0
for (const usr of users) {
try {
const resp = await API_CLIENT.post(`groups/${state.group.id}/users/${usr.id}`).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
assigned++
} catch (err) {
// -> ky throws above 400, with the reason in the body
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({
type: 'negative',
message: t('admin.groups.assignUserFailed', { userName: usr.name }),
caption: apiMessage || err.message
})
}
}
if (assigned > 0) {
$q.notify({
type: 'positive',
message: t('admin.groups.assignUserSuccess', { count: assigned })
})
}
await refreshUsers()
})
}
function unassignUser () {
async function unassignUser (user) {
$q.dialog({
title: t('admin.groups.unassignUser'),
message: t('admin.groups.unassignUserConfirm', { userName: user.name }),
cancel: true,
persistent: true
}).onOk(async () => {
state.isLoadingUsers = true
try {
const resp = await API_CLIENT.delete(`groups/${state.group.id}/users/${user.id}`)
if (!resp?.ok) {
throw new Error((await resp.json())?.message || 'An unexpected error occured.')
}
$q.notify({
type: 'positive',
message: t('admin.groups.unassignUserSuccess')
})
await refreshUsers()
} catch (err) {
// -> ky throws above 400 (e.g. 409 for the last root admin), with the reason in the body
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({
type: 'negative',
message: apiMessage || err.message
})
}
state.isLoadingUsers = false
})
}
// MOUNTED

@ -30,7 +30,7 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
<script setup>
import { cloneDeep } from 'lodash-es'
import { cloneDeep } from 'es-toolkit/object'
import { useI18n } from 'vue-i18n'
import { useDialogPluginComponent, useQuasar } from 'quasar'
import { reactive, ref } from 'vue'
@ -84,7 +84,7 @@ async function confirm () {
json: {
isEnabled: props.targetState
}
})
}).json()
if (resp?.ok) {
$q.notify({
type: 'positive',

@ -69,7 +69,7 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
<script setup>
import { sampleSize } from 'lodash-es'
import { sampleSize } from 'es-toolkit/array'
import zxcvbn from 'zxcvbn'
import { useI18n } from 'vue-i18n'
@ -172,42 +172,22 @@ async function save () {
if (!isFormValid) {
throw new Error(t('admin.users.createInvalidData'))
}
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation adminUpdateUserPwd (
$id: UUID!
$newPassword: String!
$mustChangePassword: Boolean
) {
changeUserPassword (
id: $id
newPassword: $newPassword
mustChangePassword: $mustChangePassword
) {
operation {
succeeded
message
}
}
}
`,
variables: {
id: props.userId,
const resp = await API_CLIENT.put(`users/${props.userId}/password`, {
json: {
newPassword: state.userPassword,
mustChangePassword: state.userMustChangePassword
}
})
if (resp?.data?.changeUserPassword?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('admin.users.changePasswordSuccess')
})
onDialogOK({
mustChangePassword: state.userMustChangePassword
})
} else {
throw new Error(resp?.data?.changeUserPassword?.operation?.message || 'An unexpected error occured.')
}).json()
if (!resp?.ok) {
throw new Error(t(`admin.users.${resp?.error}`, resp?.message || 'An unexpected error occured.'))
}
$q.notify({
type: 'positive',
message: t('admin.users.changePasswordSuccess')
})
onDialogOK({
mustChangePassword: state.userMustChangePassword
})
} catch (err) {
$q.notify({
type: 'negative',

@ -179,7 +179,7 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
<script setup>
import { cloneDeep, sample, sampleSize } from 'lodash-es'
import { sample, sampleSize } from 'es-toolkit/array'
import zxcvbn from 'zxcvbn'
import { useI18n } from 'vue-i18n'
import { useDialogPluginComponent, useQuasar } from 'quasar'
@ -295,18 +295,16 @@ const userGroupsValidation = [
async function loadGroups () {
state.loading++
state.loadingGroups = true
const resp = await APOLLO_CLIENT.query({
query: `
query getGroupsForCreateUser {
groups {
id
name
}
}
`,
fetchPolicy: 'network-only'
})
state.groups = cloneDeep(resp?.data?.groups?.filter(g => g.id !== '10000000-0000-4000-8000-000000000001') ?? [])
try {
const groups = await API_CLIENT.get('groups').json()
state.groups = (groups ?? []).filter(g => g.id !== '10000000-0000-4000-8000-000000000001')
} catch (err) {
$q.notify({
type: 'negative',
message: t('admin.users.groupsLoadFailed'),
caption: err.message
})
}
state.loadingGroups = false
state.loading--
}
@ -327,58 +325,33 @@ async function create () {
if (state.userSendWelcomeEmail && !state.userSendWelcomeEmailFromSiteId) {
throw new Error(t('admin.users.createSendEmailMissingSiteId'))
}
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation createUser (
$name: String!
$email: String!
$password: String!
$groups: [UUID]!
$mustChangePassword: Boolean!
$sendWelcomeEmail: Boolean!
$sendWelcomeEmailFromSiteId: UUID
) {
createUser (
name: $name
email: $email
password: $password
groups: $groups
mustChangePassword: $mustChangePassword
sendWelcomeEmail: $sendWelcomeEmail
sendWelcomeEmailFromSiteId: $sendWelcomeEmailFromSiteId
) {
operation {
succeeded
message
}
}
}
`,
variables: {
const resp = await API_CLIENT.post('users', {
json: {
name: state.userName,
email: state.userEmail,
password: state.userPassword,
groups: state.userGroups,
mustChangePassword: state.userMustChangePassword,
sendWelcomeEmail: state.userSendWelcomeEmail,
sendWelcomeEmailFromSiteId: state.userSendWelcomeEmailFromSiteId
...(state.userSendWelcomeEmailFromSiteId
? { sendWelcomeEmailFromSiteId: state.userSendWelcomeEmailFromSiteId }
: {})
}
}).json()
if (!resp?.ok) {
throw new Error(t(`admin.users.${resp?.error}`, resp?.message || 'An unexpected error occured.'))
}
$q.notify({
type: 'positive',
message: t('admin.users.createSuccess')
})
if (resp?.data?.createUser?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('admin.users.createSuccess')
})
if (state.keepOpened) {
state.userName = ''
state.userEmail = ''
state.userPassword = ''
iptName.value.focus()
} else {
onDialogOK()
}
if (state.keepOpened) {
state.userName = ''
state.userEmail = ''
state.userPassword = ''
iptName.value.focus()
} else {
throw new Error(resp?.data?.createUser?.operation?.message || 'An unexpected error occured.')
onDialogOK()
}
} catch (err) {
$q.notify({

@ -84,20 +84,10 @@ import { useI18n } from 'vue-i18n'
import { useQuasar } from 'quasar'
import { onMounted, reactive, ref } from 'vue'
import { cloneDeep } from 'lodash-es'
import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
// QUASAR
const $q = useQuasar()
// STORES
const pageStore = usePageStore()
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
@ -132,41 +122,21 @@ const timezones = Intl.supportedValuesOf('timeZone')
async function save () {
state.loading++
try {
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation saveSite (
$timezone: String!
$dateFormat: String!
$timeFormat: String!
) {
updateUserDefaults (
timezone: $timezone
dateFormat: $dateFormat
timeFormat: $timeFormat
) {
operation {
succeeded
slug
message
}
}
}
`,
variables: {
const resp = await API_CLIENT.put('users/defaults', {
json: {
timezone: state.timezone,
dateFormat: state.dateFormat,
timeFormat: state.timeFormat
}
})
if (resp?.data?.updateUserDefaults?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('admin.users.defaultsSaveSuccess')
})
menuRef.value.hide()
} else {
throw new Error(resp?.data?.updateUserDefaults?.operation?.message)
}).json()
if (!resp?.ok) {
throw new Error(t(`admin.users.${resp?.error}`, resp?.message || 'An unexpected error occured.'))
}
$q.notify({
type: 'positive',
message: t('admin.users.defaultsSaveSuccess')
})
menuRef.value.hide()
} catch (err) {
$q.notify({
type: 'negative',
@ -182,22 +152,10 @@ async function save () {
onMounted(async () => {
state.loading++
try {
const resp = await APOLLO_CLIENT.query({
query: `
query getUserDefaults {
userDefaults {
timezone
dateFormat
timeFormat
}
}
`,
fetchPolicy: 'network-only'
})
const respData = cloneDeep(resp?.data?.userDefaults)
state.timezone = respData.timezone
state.dateFormat = respData.dateFormat
state.timeFormat = respData.timeFormat
const resp = await API_CLIENT.get('users/defaults').json()
state.timezone = resp?.timezone ?? 'America/New_York'
state.dateFormat = resp?.dateFormat ?? 'YYYY-MM-DD'
state.timeFormat = resp?.timeFormat ?? '12h'
} catch (err) {
$q.notify({
type: 'negative',

@ -529,8 +529,6 @@ q-layout(view='hHh lpR fFf', container)
<script setup>
import { cloneDeep, find, map, some } from 'lodash-es'
import { DateTime } from 'luxon'
import { useI18n } from 'vue-i18n'
import { useQuasar } from 'quasar'
@ -605,11 +603,11 @@ const metadata = computed({
const localAuth = computed({
get () {
return find(state.user?.auth, ['strategyKey', 'local'])?.config ?? {}
return state.user?.auth?.find(prv => prv.strategyKey === 'local')?.config ?? {}
},
set (val) {
if (localAuth.value.authId) {
find(state.user.auth, ['strategyKey', 'local']).config = val
state.user.auth.find(prv => prv.strategyKey === 'local').config = val
}
}
})
@ -630,54 +628,15 @@ async function fetchUser () {
state.loading++
$q.loading.show()
try {
const resp = await APOLLO_CLIENT.query({
query: `
query adminFetchUser (
$id: UUID!
) {
groups {
id
name
}
userById(
id: $id
) {
id
email
name
isSystem
isVerified
isActive
auth {
authId
authName
strategyKey
strategyIcon
config
}
meta
prefs
lastLoginAt
createdAt
updatedAt
groups {
id
name
}
}
}
`,
variables: {
id: adminStore.overlayOpts.id
},
fetchPolicy: 'network-only'
})
state.groups = resp?.data?.groups?.filter(g => g.id !== '10000000-0000-4000-8000-000000000001') ?? []
if (resp?.data?.userById) {
state.user = cloneDeep(resp.data.userById)
} else {
const [groups, user] = await Promise.all([
API_CLIENT.get('groups').json(),
API_CLIENT.get(`users/${adminStore.overlayOpts.id}`).json()
])
state.groups = (groups ?? []).filter(g => g.id !== '10000000-0000-4000-8000-000000000001')
if (!user?.id) {
throw new Error('An unexpected error occured while fetching user details.')
}
state.user = user
} catch (err) {
$q.notify({
type: 'negative',
@ -703,7 +662,14 @@ function checkRoute () {
function formattedDate (val) {
if (!val) { return '---' }
return DateTime.fromISO(val).toLocaleString(DateTime.DATETIME_FULL)
return Temporal.Instant.from(val).toLocaleString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short'
})
}
function assignGroup () {
@ -712,13 +678,13 @@ function assignGroup () {
type: 'negative',
message: t('admin.users.noGroupSelected')
})
} else if (some(state.user.groups, gr => gr.id === state.groupToAdd)) {
} else if (state.user.groups.some(gr => gr.id === state.groupToAdd)) {
$q.notify({
type: 'warning',
message: t('admin.users.groupAlreadyAssigned')
})
} else {
const newGroup = find(state.groups, ['id', state.groupToAdd])
const newGroup = state.groups.find(gr => gr.id === state.groupToAdd)
state.user.groups = [...state.user.groups, newGroup]
}
}
@ -753,40 +719,20 @@ async function save (patch, { silent, keepOpen } = { silent: false, keepOpen: fa
}
}
try {
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation adminSaveUser (
$id: UUID!
$patch: UserUpdateInput!
) {
updateUser (
id: $id
patch: $patch
) {
operation {
succeeded
message
}
}
}
`,
variables: {
id: adminStore.overlayOpts.id,
patch
}
})
if (resp?.data?.updateUser?.operation?.succeeded) {
if (!silent) {
$q.notify({
type: 'positive',
message: t('admin.users.saveSuccess')
})
}
if (!keepOpen) {
close()
}
} else {
throw new Error(resp?.data?.updateUser?.operation?.message || 'An unexpected error occured.')
const resp = await API_CLIENT.put(`users/${adminStore.overlayOpts.id}`, {
json: patch
}).json()
if (!resp?.ok) {
throw new Error(t(`admin.users.${resp?.error}`, resp?.message || 'An unexpected error occured.'))
}
if (!silent) {
$q.notify({
type: 'positive',
message: t('admin.users.saveSuccess')
})
}
if (!keepOpen) {
close()
}
} catch (err) {
$q.notify({

@ -0,0 +1,208 @@
<template lang="pug">
q-dialog(ref='dialogRef', @hide='onDialogHide')
q-card.user-search-dialog(style='width: 600px; max-width: 90vw;')
q-card-section.card-header
q-icon(name='img:/_assets/icons/fluent-account.svg', left, size='sm')
span {{ props.title || t('admin.users.selectUsers') }}
q-card-section.q-py-sm
q-input(
outlined
dense
v-model='state.search'
:placeholder='t(`admin.users.searchUsers`)'
:aria-label='t(`admin.users.searchUsers`)'
clearable
hide-bottom-space
autofocus
)
template(#prepend)
q-icon(name='las la-search')
q-separator
.user-search-dialog-list
q-inner-loading(:showing='state.loading > 0')
.flex.flex-center.full-height.q-pa-md(v-if='state.users.length < 1 && state.loading < 1')
.text-grey {{ t('admin.users.searchNoResults') }}
q-list(v-else, separator)
q-item(
v-for='usr of state.users'
:key='usr.id'
clickable
v-ripple
@click='toggle(usr)'
)
q-item-section(side)
//- .stop keeps the click from also reaching the item handler, which would toggle twice
q-checkbox(
:model-value='isSelected(usr.id)'
@update:model-value='toggle(usr)'
@click.stop
:aria-label='usr.name'
dense
)
q-item-section(avatar)
q-avatar(v-if='usr.hasAvatar', size='md')
img(:src='`/_user/` + usr.id + `/avatar`')
q-avatar(v-else, size='md', color='primary', text-color='white', icon='las la-user')
q-item-section
q-item-label {{ usr.name }}
q-item-label(caption) {{ usr.email }}
q-item-section(side)
.flex.items-center
q-icon.q-ml-sm(v-if='usr.isSystem', name='las la-lock', color='pink')
q-tooltip {{ t('admin.users.systemUser') }}
q-icon.q-ml-sm(v-if='!usr.isActive', name='las la-ban', color='pink')
q-tooltip {{ t('admin.users.inactive') }}
q-icon.q-ml-sm(v-if='!usr.isVerified', name='las la-envelope', color='orange')
q-tooltip {{ t('admin.users.unverified') }}
q-separator
.flex.flex-center.q-py-sm(v-if='totalPages > 1')
q-pagination(
v-model='state.currentPage'
:max='totalPages'
:max-pages='7'
boundary-numbers
direction-links
)
q-card-actions.card-actions
.text-caption.text-grey.q-ml-sm(v-if='state.selected.length > 0')
| {{ t('admin.users.selectedCount', { count: state.selected.length }) }}
q-space
q-btn.acrylic-btn(
flat
:label='t(`common.actions.cancel`)'
color='grey'
padding='xs md'
@click='onDialogCancel'
)
q-btn(
unelevated
:label='t(`common.actions.select`)'
color='primary'
padding='xs md'
:disable='state.selected.length < 1'
@click='confirm'
)
</template>
<script setup>
import { debounce } from 'es-toolkit/function'
import { useI18n } from 'vue-i18n'
import { useDialogPluginComponent, useQuasar } from 'quasar'
import { computed, onMounted, reactive, watch } from 'vue'
// PROPS
const props = defineProps({
/** Dialog title. Defaults to a generic "Select Users". */
title: {
type: String,
required: false,
default: ''
},
/**
* Offer only the users that may be assigned to this group. Filtering happens server-side, as
* group membership can span more pages than are displayed.
*/
assignableToGroupId: {
type: String,
required: false,
default: ''
}
})
// EMITS
defineEmits([...useDialogPluginComponent.emits])
// QUASAR
const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
const $q = useQuasar()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
users: [],
selected: [],
search: '',
loading: 0,
total: 0,
currentPage: 1,
pageSize: 10
})
// COMPUTED
const totalPages = computed(() => Math.ceil(state.total / state.pageSize))
// WATCHERS
watch(
() => state.search,
debounce(() => {
state.currentPage = 1
load()
}, 400)
)
watch(() => state.currentPage, load)
// METHODS
async function load() {
state.loading++
try {
const resp = await API_CLIENT.get('users', {
searchParams: {
...(state.search ? { filter: state.search } : {}),
...(props.assignableToGroupId ? { assignableToGroupId: props.assignableToGroupId } : {}),
page: state.currentPage,
limit: state.pageSize
}
}).json()
state.total = resp?.total ?? 0
state.users = resp?.users ?? []
} catch (err) {
$q.notify({
type: 'negative',
message: t('admin.users.loadFailed'),
caption: err.message
})
}
state.loading--
}
function isSelected(id) {
return state.selected.some((usr) => usr.id === id)
}
/** Selection survives filtering and paging, so users from several pages can be picked at once. */
function toggle(usr) {
state.selected = isSelected(usr.id)
? state.selected.filter((sel) => sel.id !== usr.id)
: [...state.selected, usr]
}
function confirm() {
onDialogOK(state.selected)
}
// MOUNTED
onMounted(load)
</script>
<style lang="scss">
.user-search-dialog {
&-list {
position: relative;
height: 360px;
max-height: 50vh;
overflow-y: auto;
}
}
</style>

@ -7,6 +7,7 @@ import { initializeComponents } from './boot/components'
import { initializeEventBus } from './boot/eventbus'
import { initializeExternals } from './boot/externals'
import { initializeI18n } from './boot/i18n'
import { initializeTemporal } from './boot/temporal'
import quasarIconSet from 'quasar/icon-set/mdi-v7'
// Import icon libraries
@ -20,6 +21,9 @@ import './css/app.scss'
import RootApp from './App.vue'
// Must come first: everything below may use Temporal, directly or indirectly.
await initializeTemporal()
const router = initializeRouter()
const store = initializeStore(router)

@ -79,16 +79,15 @@ q-page.admin-flags
unchecked-icon='las la-times'
:label='t(`admin.blocks.isEnabled`)'
:aria-label='t(`admin.blocks.isEnabled`)'
disable
)
</template>
<script setup>
import { useMeta, useQuasar } from 'quasar'
import { useI18n } from 'vue-i18n'
import { defineAsyncComponent, onMounted, reactive, watch } from 'vue'
import { onMounted, reactive, watch } from 'vue'
import { cloneDeep, pick } from 'lodash-es'
import { pick } from 'es-toolkit/object'
import { useAdminStore } from '@/stores/admin'
import { useFlagsStore } from '@/stores/flags'
@ -131,34 +130,12 @@ watch(() => adminStore.currentSiteId, (newValue) => {
async function load () {
state.loading++
try {
const resp = await APOLLO_CLIENT.query({
query: `
query getSiteBlocks (
$siteId: UUID!
) {
blocks (
siteId: $siteId
) {
id
block
name
description
icon
isEnabled
isCustom
config
}
}`,
variables: {
siteId: adminStore.currentSiteId
},
fetchPolicy: 'network-only'
})
state.blocks = cloneDeep(resp?.data?.blocks)
state.blocks = await API_CLIENT.get(`sites/${adminStore.currentSiteId}/blocks`).json() ?? []
} catch (err) {
$q.notify({
type: 'negative',
message: 'Failed to fetch blocks state.'
message: t('admin.blocks.loadFailed'),
caption: err.message
})
}
$q.loading.hide()
@ -168,41 +145,22 @@ async function load () {
async function save () {
state.loading++
try {
const respRaw = await APOLLO_CLIENT.mutate({
mutation: `
mutation saveSiteBlocks (
$siteId: UUID!
$states: [BlockStateInput]!
) {
setBlocksState (
siteId: $siteId,
states: $states
) {
operation {
succeeded
slug
message
}
}
}
`,
variables: {
siteId: adminStore.currentSiteId,
const resp = await API_CLIENT.put(`sites/${adminStore.currentSiteId}/blocks`, {
json: {
states: state.blocks.map(bl => pick(bl, ['id', 'isEnabled']))
}
})
if (respRaw?.data?.setBlocksState?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('admin.blocks.saveSuccess')
})
} else {
throw new Error(respRaw?.data?.setBlocksState?.operation?.message || 'An unexpected error occured.')
}).json()
if (!resp?.ok) {
throw new Error(t(`admin.blocks.${resp?.error}`, resp?.message || 'An unexpected error occured.'))
}
$q.notify({
type: 'positive',
message: t('admin.blocks.saveSuccess')
})
} catch (err) {
$q.notify({
type: 'negative',
message: 'Failed to save site blocks state',
message: t('admin.blocks.saveFailed'),
caption: err.message
})
}
@ -214,11 +172,43 @@ async function refresh () {
}
function addBlock () {
// TODO: registering a custom block means uploading a compiled component, which needs an upload
// endpoint that does not exist yet. Built-in blocks come from the compiled block manifest.
$q.notify({
type: 'warning',
message: t('admin.blocks.addUnavailable')
})
}
function deleteBlock (id) {
const block = state.blocks.find(bl => bl.id === id)
$q.dialog({
title: t('admin.blocks.delete'),
message: t('admin.blocks.deleteConfirm', { blockName: block?.name ?? '' }),
cancel: true,
persistent: true
}).onOk(async () => {
state.loading++
try {
const resp = await API_CLIENT.delete(`sites/${adminStore.currentSiteId}/blocks/${id}`)
if (!resp?.ok) {
throw new Error((await resp.json())?.message || 'An unexpected error occured.')
}
$q.notify({
type: 'positive',
message: t('admin.blocks.deleteSuccess')
})
await load()
} catch (err) {
// -> ky throws above 400 (e.g. 409 for a built-in block), with the reason in the body
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({
type: 'negative',
message: apiMessage || err.message
})
}
state.loading--
})
}
// MOUNTED

@ -76,9 +76,7 @@ q-page.admin-flags
<script setup>
import { useMeta, useQuasar } from 'quasar'
import { useI18n } from 'vue-i18n'
import { defineAsyncComponent, onMounted, reactive, watch } from 'vue'
import { cloneDeep } from 'lodash-es'
import { onMounted, reactive, watch } from 'vue'
import { useAdminStore } from '@/stores/admin'
import { useFlagsStore } from '@/stores/flags'
@ -172,34 +170,8 @@ watch(() => adminStore.currentSiteId, (newValue) => {
async function load () {
state.loading++
try {
const resp = await APOLLO_CLIENT.query({
query: `
query getEditorsState (
$siteId: UUID!
) {
siteById (
id: $siteId
) {
id
editors {
asciidoc {
isActive
}
markdown {
isActive
}
wysiwyg {
isActive
}
}
}
}`,
variables: {
siteId: adminStore.currentSiteId
},
fetchPolicy: 'network-only'
})
const data = cloneDeep(resp?.data?.siteById?.editors)
const resp = await API_CLIENT.get(`sites/${adminStore.currentSiteId}?strict=true`).json()
const data = resp?.editors
state.config.asciidoc = data?.asciidoc?.isActive ?? false
state.config.markdown = data?.markdown?.isActive ?? false
state.config.wysiwyg = data?.wysiwyg?.isActive ?? false
@ -216,52 +188,32 @@ async function load () {
async function save () {
state.loading++
try {
const respRaw = await APOLLO_CLIENT.mutate({
mutation: `
mutation saveEditorState (
$id: UUID!
$patch: SiteUpdateInput!
) {
updateSite (
id: $id,
patch: $patch
) {
operation {
succeeded
slug
message
}
}
// -> Only `isActive` is sent, so each editor's own `config` is left untouched by the merge
const resp = await API_CLIENT.put(`sites/${adminStore.currentSiteId}`, {
json: {
editors: {
asciidoc: { isActive: state.config.asciidoc },
markdown: { isActive: state.config.markdown },
wysiwyg: { isActive: state.config.wysiwyg }
}
`,
variables: {
id: adminStore.currentSiteId,
patch: {
editors: {
asciidoc: { isActive: state.config.asciidoc },
markdown: { isActive: state.config.markdown },
wysiwyg: { isActive: state.config.wysiwyg }
}
}
}
})
if (respRaw?.data?.updateSite?.operation?.succeeded) {
if (adminStore.currentSiteId === siteStore.id) {
siteStore.$patch({
editors: {
asciidoc: state.config.asciidoc,
markdown: state.config.markdown,
wysiwyg: state.config.wysiwyg
}
})
}
$q.notify({
type: 'positive',
message: t('admin.editors.saveSuccess')
}).json()
if (!resp?.ok) {
throw new Error(t(`admin.editors.${resp?.error}`, resp?.message || 'An unexpected error occured.'))
}
if (adminStore.currentSiteId === siteStore.id) {
siteStore.$patch({
editors: {
asciidoc: state.config.asciidoc,
markdown: state.config.markdown,
wysiwyg: state.config.wysiwyg
}
})
} else {
throw new Error(respRaw?.data?.updateSite?.operation?.message || 'An unexpected error occured.')
}
$q.notify({
type: 'positive',
message: t('admin.editors.saveSuccess')
})
} catch (err) {
$q.notify({
type: 'negative',

@ -499,7 +499,7 @@ q-page.admin-general
<script setup>
import { cloneDeep } from 'lodash-es'
import { toMerged } from 'es-toolkit/object'
import { useI18n } from 'vue-i18n'
import { useMeta, useQuasar } from 'quasar'
import { onMounted, reactive, watch } from 'vue'
@ -528,10 +528,12 @@ useMeta({
// DATA
const state = reactive({
loading: 0,
assetTimestamp: (new Date()).toISOString(),
config: {
/**
* Fallbacks for config keys a site may not have stored yet, so that every control renders with a
* defined value. Must mirror the defaults used by the backend when creating a site.
*/
function defaultConfig () {
return {
hostname: '',
title: '',
description: '',
@ -550,7 +552,7 @@ const state = reactive({
ratingsMode: 'off',
comments: false,
contributions: false,
reasonForChange: 'off',
reasonForChange: 'required',
profile: false
},
discoverable: false,
@ -569,6 +571,12 @@ const state = reactive({
},
sitemap: false
}
}
const state = reactive({
loading: 0,
assetTimestamp: (new Date()).toISOString(),
config: defaultConfig()
})
const contentLicenses = [
@ -617,75 +625,66 @@ async function load () {
state.loading++
$q.loading.show()
const resp = await API_CLIENT.get(`sites/${adminStore.currentSiteId}?strict=true`).json()
state.config = {
state.config = toMerged(defaultConfig(), {
...resp,
pageExtensions: resp.pageExtensions.join(',')
}
})
$q.loading.hide()
state.loading--
}
/**
* The form holds page extensions as a comma-separated string, while the API expects an array.
*/
function parsePageExtensions (value) {
const extensions = Array.isArray(value) ? value : String(value ?? '').split(',')
return [...new Set(extensions.map(ext => ext.trim().toLowerCase()).filter(ext => ext.length > 0))]
}
async function save () {
state.loading++
try {
await APOLLO_CLIENT.mutate({
mutation: `
mutation saveSite (
$id: UUID!
$patch: SiteUpdateInput!
) {
updateSite (
id: $id
patch: $patch
) {
operation {
succeeded
slug
message
}
}
}
`,
variables: {
id: adminStore.currentSiteId,
patch: {
hostname: state.config.hostname ?? '',
title: state.config.title ?? '',
description: state.config.description ?? '',
company: state.config.company ?? '',
contentLicense: state.config.contentLicense ?? '',
footerExtra: state.config.footerExtra ?? '',
pageExtensions: state.config.pageExtensions ?? '',
pageCasing: state.config.pageCasing ?? false,
logoText: state.config.logoText ?? false,
sitemap: state.config.sitemap ?? false,
uploads: {
conflictBehavior: state.config.uploads?.conflictBehavior ?? 'overwrite',
normalizeFilename: state.config.uploads?.normalizeFilename ?? false
},
robots: {
index: state.config.robots?.index ?? false,
follow: state.config.robots?.follow ?? false
},
features: {
browse: state.config.features?.browse ?? false,
comments: state.config.features?.comments ?? false,
ratings: (state.config.features?.ratings || 'off') !== 'off',
ratingsMode: state.config.features?.ratingsMode ?? 'off',
contributions: state.config.features?.contributions ?? false,
profile: state.config.features?.profile ?? false,
search: state.config.features?.search ?? false
},
discoverable: state.config.discoverable ?? false,
defaults: {
tocDepth: {
min: state.config.defaults?.tocDepth?.min ?? 1,
max: state.config.defaults?.tocDepth?.max ?? 2
}
const resp = await API_CLIENT.put(`sites/${adminStore.currentSiteId}`, {
json: {
hostname: state.config.hostname ?? '',
title: state.config.title ?? '',
description: state.config.description ?? '',
company: state.config.company ?? '',
contentLicense: state.config.contentLicense ?? '',
footerExtra: state.config.footerExtra ?? '',
pageExtensions: parsePageExtensions(state.config.pageExtensions),
pageCasing: state.config.pageCasing ?? false,
logoText: state.config.logoText ?? false,
sitemap: state.config.sitemap ?? false,
uploads: {
conflictBehavior: state.config.uploads?.conflictBehavior ?? 'overwrite',
normalizeFilename: state.config.uploads?.normalizeFilename ?? false
},
robots: {
index: state.config.robots?.index ?? false,
follow: state.config.robots?.follow ?? false
},
features: {
browse: state.config.features?.browse ?? false,
comments: state.config.features?.comments ?? false,
ratingsMode: state.config.features?.ratingsMode ?? 'off',
contributions: state.config.features?.contributions ?? false,
profile: state.config.features?.profile ?? false,
reasonForChange: state.config.features?.reasonForChange ?? 'required',
search: state.config.features?.search ?? false
},
discoverable: state.config.discoverable ?? false,
defaults: {
tocDepth: {
min: state.config.defaults?.tocDepth?.min ?? 1,
max: state.config.defaults?.tocDepth?.max ?? 2
}
}
}
})
}).json()
if (!resp?.ok) {
throw new Error(t(`admin.general.${resp?.error}`, resp?.message || 'An unexpected error occured.'))
}
$q.notify({
type: 'positive',
message: t('admin.general.saveSuccess')

@ -97,10 +97,9 @@ q-page.admin-groups
<script setup>
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { useMeta, useQuasar } from 'quasar'
import { computed, onBeforeUnmount, onMounted, reactive, watch } from 'vue'
import { onBeforeUnmount, onMounted, reactive, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAdminStore } from '@/stores/admin'
@ -191,22 +190,7 @@ async function load () {
state.loading++
$q.loading.show()
try {
const resp = await APOLLO_CLIENT.query({
query: `
query getGroups {
groups {
id
name
isSystem
userCount
createdAt
updatedAt
}
}
`,
fetchPolicy: 'network-only'
})
state.groups = cloneDeep(resp?.data?.groups)
state.groups = await API_CLIENT.get('groups').json()
} catch (err) {
$q.notify({
type: 'negative',

@ -111,11 +111,11 @@ q-page.admin-locale
<script setup>
import { cloneDeep, sortBy } from 'lodash-es'
import { sortBy } from 'es-toolkit/array'
import { useI18n } from 'vue-i18n'
import { useMeta, useQuasar } from 'quasar'
import { computed, onMounted, reactive, watch } from 'vue'
import { onMounted, reactive, watch } from 'vue'
import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site'
@ -154,9 +154,10 @@ const state = reactive({
watch(() => adminStore.currentSiteId, (newValue) => {
load()
})
watch(() => state.selectedLocale, (newValue) => {
if (!state.namespaces.includes(newValue)) {
state.namespaces.push(newValue)
// -> Selecting a primary locale that isn't active yet activates it, since its toggle is disabled
watch(() => state.primary, (newValue) => {
if (newValue && !state.active.includes(newValue)) {
state.active.push(newValue)
}
})
@ -165,96 +166,68 @@ watch(() => state.selectedLocale, (newValue) => {
async function load () {
state.loading++
$q.loading.show()
const resp = await APOLLO_CLIENT.query({
query: `
query getLocales ($siteId: UUID!) {
locales {
completeness
code
createdAt
isRTL
language
name
nativeName
region
script
updatedAt
}
siteById(
id: $siteId
) {
id
locales {
primary
active {
code
}
}
}
}
`,
variables: {
siteId: adminStore.currentSiteId
},
fetchPolicy: 'network-only'
})
state.locales = sortBy(cloneDeep(resp?.data?.locales), ['nativeName', 'name'])
state.primary = cloneDeep(resp?.data?.siteById?.locales?.primary)
state.active = cloneDeep(resp?.data?.siteById?.locales?.active ?? []).map(l => l.code)
if (!state.active.includes(state.primary)) {
state.active.push(state.primary)
try {
const [locales, site] = await Promise.all([
API_CLIENT.get('locales').json(),
API_CLIENT.get(`sites/${adminStore.currentSiteId}?strict=true`).json()
])
state.locales = sortBy(locales ?? [], ['nativeName', 'name'])
state.primary = site?.locales?.primary ?? 'en'
state.forcePrefix = site?.locales?.forcePrefix ?? false
state.active = [...(site?.locales?.active ?? [])]
// -> The primary locale is always active, and its toggle is disabled to keep it that way
if (!state.active.includes(state.primary)) {
state.active.push(state.primary)
}
} catch (err) {
$q.notify({
type: 'negative',
message: t('admin.locale.loadFailed'),
caption: err.message
})
}
$q.loading.hide()
state.loading--
}
async function save () {
state.loading = true
const respRaw = await APOLLO_CLIENT.mutate({
mutation: `
mutation saveLocaleSettings (
$locale: String!
$autoUpdate: Boolean!
$namespacing: Boolean!
$namespaces: [String]!
) {
localization {
updateLocale(
locale: $locale
autoUpdate: $autoUpdate
namespacing: $namespacing
namespaces: $namespaces
) {
responseResult {
succeeded
errorCode
slug
message
}
}
if (state.loading > 0) { return }
state.loading++
try {
// -> The primary locale is always active, even if the user just switched to an inactive one
const active = [...new Set(state.active)]
if (!active.includes(state.primary)) {
active.push(state.primary)
}
const resp = await API_CLIENT.put(`sites/${adminStore.currentSiteId}`, {
json: {
locales: {
primary: state.primary,
active,
forcePrefix: state.forcePrefix
}
}
`,
variables: {
locale: state.selectedLocale,
autoUpdate: state.autoUpdate,
namespacing: state.namespacing,
namespaces: state.namespaces
}).json()
if (!resp?.ok) {
throw new Error(t(`admin.locale.${resp?.error}`, resp?.message || 'An unexpected error occured.'))
}
})
const resp = respRaw?.data?.localization?.updateLocale?.responseResult || {}
if (resp.succeeded) {
state.active = active
$q.notify({
type: 'positive',
message: 'Locale settings updated successfully.'
message: t('admin.locale.saveSuccess')
})
} else {
await adminStore.fetchSites()
if (adminStore.currentSiteId === siteStore.id) {
siteStore.loadSite(window.location.hostname)
}
} catch (err) {
$q.notify({
type: 'negative',
message: resp.message
message: err.message
})
}
state.loading = false
state.loading--
}
// MOUNTED

@ -65,7 +65,7 @@ q-page.admin-login
q-item-label(caption) {{t(`admin.login.bypassScreenHint`)}}
q-item-section(avatar)
q-toggle(
v-model='state.config.authAutoLogin'
v-model='state.config.autoLogin'
color='primary'
checked-icon='las la-check'
unchecked-icon='las la-times'
@ -79,7 +79,7 @@ q-page.admin-login
q-item-label(caption) {{t(`admin.login.bypassUnauthorizedHint`)}}
q-item-section(avatar)
q-toggle(
v-model='state.config.authBypassUnauthorized'
v-model='state.config.bypassUnauthorized'
color='primary'
checked-icon='las la-check'
unchecked-icon='las la-times'
@ -180,13 +180,13 @@ q-page.admin-login
</template>
<script setup>
import { cloneDeep } from 'lodash-es'
import { toMerged } from 'es-toolkit/object'
import { Sortable } from 'sortablejs-vue3'
import { useI18n } from 'vue-i18n'
import { useMeta, useQuasar } from 'quasar'
import { computed, onMounted, reactive, watch } from 'vue'
import { onMounted, reactive, watch } from 'vue'
import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site'
@ -212,17 +212,25 @@ useMeta({
// DATA
const state = reactive({
invalidCharsRegex: /^[^<>"]+$/,
loading: 0,
config: {
authAutoLogin: false,
authHideLocal: false,
authBypassUnauthorized: false,
/**
* Fallbacks for keys a site may not have stored yet, so that every control renders with a defined
* value. Must mirror the `auth` defaults used by the backend when creating a site.
*/
function defaultConfig () {
return {
autoLogin: false,
bypassUnauthorized: false,
hideLocal: false,
loginRedirect: '/',
welcomeRedirect: '/',
logoutRedirect: '/'
},
}
}
const state = reactive({
invalidCharsRegex: /^[^<>"]+$/,
loading: 0,
config: defaultConfig(),
providers: []
})
@ -242,34 +250,22 @@ watch(() => adminStore.currentSiteId, (newValue) => {
async function load () {
state.loading++
$q.loading.show()
const resp = await APOLLO_CLIENT.query({
query: `
query getSiteAuthStrategies (
$siteId: UUID!
) {
authSiteStrategies(
siteId: $siteId
visibleOnly: false
) {
id
activeStrategy {
displayName
strategy {
key
title
icon
}
}
isVisible
}
}
`,
variables: {
siteId: adminStore.currentSiteId
},
fetchPolicy: 'network-only'
})
state.providers = cloneDeep(resp?.data?.authSiteStrategies)
try {
const [site, providers] = await Promise.all([
API_CLIENT.get(`sites/${adminStore.currentSiteId}?strict=true`).json(),
API_CLIENT.get(`sites/${adminStore.currentSiteId}/auth/strategies`, {
searchParams: { visibleOnly: false }
}).json()
])
state.config = toMerged(defaultConfig(), site?.auth ?? {})
state.providers = providers ?? []
} catch (err) {
$q.notify({
type: 'negative',
message: t('admin.login.loadFailed'),
caption: err.message
})
}
$q.loading.hide()
state.loading--
}
@ -277,31 +273,27 @@ async function load () {
async function save () {
state.loading++
try {
await APOLLO_CLIENT.mutate({
mutation: `
mutation saveLoginConfig (
$id: UUID!
$patch: SiteUpdateInput!
) {
updateSite (
id: $id
patch: $patch
) {
operation {
succeeded
message
}
}
}
`,
variables: {
id: adminStore.currentSiteId,
patch: {
authAutoLogin: state.config.authAutoLogin ?? false,
authEnforce2FA: state.config.authEnforce2FA ?? false
}
const resp = await API_CLIENT.put(`sites/${adminStore.currentSiteId}`, {
json: {
auth: {
autoLogin: state.config.autoLogin ?? false,
bypassUnauthorized: state.config.bypassUnauthorized ?? false,
hideLocal: state.config.hideLocal ?? false,
loginRedirect: state.config.loginRedirect ?? '/',
welcomeRedirect: state.config.welcomeRedirect ?? '/',
logoutRedirect: state.config.logoutRedirect ?? '/'
},
// -> Order comes from the current position in the drag-sortable list
authStrategies: state.providers.map((provider, index) => ({
id: provider.id,
order: index,
isVisible: provider.isVisible ?? false
}))
}
})
}).json()
if (!resp?.ok) {
throw new Error(t(`admin.login.${resp?.error}`, resp?.message || 'An unexpected error occured.'))
}
$q.notify({
type: 'positive',
message: t('admin.login.saveSuccess')
@ -321,57 +313,13 @@ function updateAuthPosition (ev) {
state.providers.splice(ev.newIndex, 0, item)
}
async function uploadBg () {
const input = document.createElement('input')
input.type = 'file'
input.onchange = async e => {
state.loading++
try {
const resp = await APOLLO_CLIENT.mutate({
context: {
uploadMode: true
},
mutation: `
mutation uploadLoginBg (
$id: UUID!
$image: Upload!
) {
uploadSiteLoginBg (
id: $id
image: $image
) {
operation {
succeeded
message
}
}
}
`,
variables: {
id: adminStore.currentSiteId,
image: e.target.files[0]
}
})
if (resp?.data?.uploadSiteLoginBg?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('admin.login.bgUploadSuccess')
})
} else {
throw new Error(resp?.data?.uploadSiteLoginBg?.operation?.message || 'An unexpected error occured.')
}
} catch (err) {
$q.notify({
type: 'negative',
message: 'Failed to upload login background image.',
caption: err.message
})
}
state.loading--
}
input.click()
function uploadBg () {
// TODO: needs a multipart upload endpoint for site assets, which does not exist yet the same
// blocker as the logo and favicon uploads in the general view.
$q.notify({
type: 'warning',
message: t('admin.login.bgUploadUnavailable')
})
}
// MOUNTED

@ -315,12 +315,11 @@ q-page.admin-mail
</template>
<script setup>
import { cloneDeep, toSafeInteger } from 'lodash-es'
import { toMerged } from 'es-toolkit/object'
import { useI18n } from 'vue-i18n'
import { useMeta, useQuasar } from 'quasar'
import { computed, onMounted, reactive, watch } from 'vue'
import { onMounted, reactive } from 'vue'
import { useAdminStore } from '@/stores/admin'
import { useFlagsStore } from '@/stores/flags'
@ -348,22 +347,31 @@ useMeta({
// DATA
const state = reactive({
config: {
/**
* Fallbacks for config keys the API may not return yet, so that every control renders with a
* defined value. Must mirror the mail defaults seeded by the backend.
*/
function defaultConfig () {
return {
senderName: '',
senderEmail: '',
defaultBaseURL: '',
host: '',
port: 0,
secure: false,
verifySSL: false,
port: 465,
name: '',
secure: true,
verifySSL: true,
user: '',
pass: '',
useDKIM: false,
dkimDomainName: '',
dkimKeySelector: '',
dkimPrivateKey: ''
},
}
}
const state = reactive({
config: defaultConfig(),
testEmail: '',
testLoading: false,
loading: 0
@ -373,32 +381,11 @@ const state = reactive({
async function load () {
state.loading++
try {
const resp = await APOLLO_CLIENT.query({
query: `
query getMailConfig {
mailConfig {
senderName
senderEmail
defaultBaseURL
host
port
secure
verifySSL
user
pass
useDKIM
dkimDomainName
dkimKeySelector
dkimPrivateKey
}
}
`,
fetchPolicy: 'network-only'
})
if (!resp?.data?.mailConfig) {
const resp = await API_CLIENT.get('mail/config').json()
if (!resp) {
throw new Error('Failed to fetch mail config.')
}
state.config = cloneDeep(resp.data.mailConfig)
state.config = toMerged(defaultConfig(), resp)
adminStore.info.isMailConfigured = state.config?.host?.length > 2
} catch (err) {
$q.notify({
@ -415,54 +402,13 @@ async function save () {
state.loading++
try {
await APOLLO_CLIENT.mutate({
mutation: `
mutation saveMailConfig (
$senderName: String!
$senderEmail: String!
$defaultBaseURL: String!
$host: String!
$port: Int!
$name: String!
$secure: Boolean!
$verifySSL: Boolean!
$user: String!
$pass: String!
$useDKIM: Boolean!
$dkimDomainName: String!
$dkimKeySelector: String!
$dkimPrivateKey: String!
) {
updateMailConfig (
senderName: $senderName
senderEmail: $senderEmail
defaultBaseURL: $defaultBaseURL
host: $host
port: $port
name: $name
secure: $secure
verifySSL: $verifySSL
user: $user
pass: $pass
useDKIM: $useDKIM
dkimDomainName: $dkimDomainName
dkimKeySelector: $dkimKeySelector
dkimPrivateKey: $dkimPrivateKey
) {
operation {
succeeded
slug
message
}
}
}
`,
variables: {
const resp = await API_CLIENT.put('mail/config', {
json: {
senderName: state.config.senderName || '',
senderEmail: state.config.senderEmail || '',
defaultBaseURL: state.config.defaultBaseURL || '',
host: state.config.host || '',
port: toSafeInteger(state.config.port) || 0,
port: Number.parseInt(state.config.port, 10) || 465,
name: state.config.name || '',
secure: state.config.secure ?? false,
verifySSL: state.config.verifySSL ?? false,
@ -473,7 +419,10 @@ async function save () {
dkimKeySelector: state.config.dkimKeySelector || '',
dkimPrivateKey: state.config.dkimPrivateKey || ''
}
})
}).json()
if (!resp?.ok) {
throw new Error(t(`admin.mail.${resp?.error}`, resp?.message || 'An unexpected error occured.'))
}
$q.notify({
type: 'positive',
message: t('admin.mail.saveSuccess')
@ -495,45 +444,13 @@ function editTemplate (tmplId) {
})
}
async function sendTest () {
state.loading++
try {
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation sentMailTest (
$recipientEmail: String!
) {
sendMailTest(
recipientEmail: $recipientEmail
) {
operation {
succeeded
slug
message
}
}
}
`,
variables: {
recipientEmail: state.testEmail
}
})
if (!resp?.data?.sendMailTest?.operation?.succeeded) {
throw new Error(resp?.data?.sendMailTest?.operation?.message || 'An unexpected error occurred.')
}
state.testEmail = ''
$q.notify({
type: 'positive',
message: t('admin.mail.sendTestSuccess')
})
} catch (err) {
$q.notify({
type: 'negative',
message: err.message
})
}
state.loading--
function sendTest () {
// TODO: the backend has no SMTP transport yet, so there is nothing to send the test email with.
// Only the mail configuration itself is wired up (GET / PUT /_api/mail/config).
$q.notify({
type: 'warning',
message: t('admin.mail.sendTestUnavailable')
})
}
// MOUNTED

@ -163,7 +163,6 @@ function editSite (st) {
})
}
function toggleSiteState (st, newState) {
console.info(newState)
$q.dialog({
component: SiteActivateDialog,
componentProps: {

@ -304,9 +304,10 @@ q-page.admin-theme
<script setup>
import { cloneDeep, startCase } from 'lodash-es'
import { toMerged } from 'es-toolkit/object'
import { startCase } from 'es-toolkit/string'
import { useI18n } from 'vue-i18n'
import { setCssVar, useMeta, useQuasar } from 'quasar'
import { useMeta, useQuasar } from 'quasar'
import { onMounted, reactive, watch } from 'vue'
import { useAdminStore } from '@/stores/admin'
@ -337,27 +338,35 @@ useMeta({
// DATA
const state = reactive({
loading: 0,
config: {
/**
* Fallbacks for theme keys a site may not have stored yet, so that every control renders with a
* defined value. Must mirror the theme defaults used by the backend when creating a site.
*/
function defaultConfig () {
return {
dark: false,
injectCSS: '',
injectHead: '',
injectBody: '',
colorPrimary: '#1976D2',
colorSecondary: '#02C39A',
colorAccent: '#f03a47',
colorHeader: '#000',
colorAccent: '#FF9800',
colorHeader: '#000000',
colorSidebar: '#1976D2',
codeBlocksTheme: '',
codeBlocksTheme: 'github-dark',
contentWidth: 'full',
sidebarPosition: 'left',
tocPosition: 'right',
showSharingMenu: true,
showPrintBtn: true,
baseFont: '',
contentFont: ''
baseFont: 'roboto',
contentFont: 'roboto'
}
}
const state = reactive({
loading: 0,
config: defaultConfig()
})
const colorKeys = [
@ -669,45 +678,11 @@ async function load () {
state.loading++
$q.loading.show()
try {
const resp = await APOLLO_CLIENT.query({
query: `
query fetchThemeConfig (
$id: UUID!
) {
siteById(
id: $id
) {
theme {
baseFont
codeBlocksTheme
contentFont
colorPrimary
colorSecondary
colorAccent
colorHeader
colorSidebar
dark
injectCSS
injectHead
injectBody
contentWidth
sidebarPosition
tocPosition
showSharingMenu
showPrintBtn
}
}
}
`,
variables: {
id: adminStore.currentSiteId
},
fetchPolicy: 'network-only'
})
if (!resp?.data?.siteById?.theme) {
const resp = await API_CLIENT.get(`sites/${adminStore.currentSiteId}?strict=true`).json()
if (!resp?.theme) {
throw new Error('Failed to fetch theme config.')
}
state.config = cloneDeep(resp.data.siteById.theme)
state.config = toMerged(defaultConfig(), resp.theme)
} catch (err) {
$q.notify({
type: 'negative',
@ -740,45 +715,24 @@ async function save () {
baseFont: state.config.baseFont,
contentFont: state.config.contentFont
}
const respRaw = await APOLLO_CLIENT.mutate({
mutation: `
mutation saveTheme (
$id: UUID!
$patch: SiteUpdateInput!
) {
updateSite (
id: $id,
patch: $patch
) {
operation {
succeeded
slug
message
}
}
}
`,
variables: {
id: adminStore.currentSiteId,
patch: {
theme: patchTheme
}
}
})
if (respRaw?.data?.updateSite?.operation?.succeeded) {
if (adminStore.currentSiteId === siteStore.id) {
siteStore.$patch({
theme: patchTheme
})
EVENT_BUS.emit('applyTheme')
const resp = await API_CLIENT.put(`sites/${adminStore.currentSiteId}`, {
json: {
theme: patchTheme
}
$q.notify({
type: 'positive',
message: t('admin.theme.saveSuccess')
}).json()
if (!resp?.ok) {
throw new Error(t(`admin.theme.${resp?.error}`, resp?.message || 'An unexpected error occured.'))
}
if (adminStore.currentSiteId === siteStore.id) {
siteStore.$patch({
theme: patchTheme
})
} else {
throw new Error(respRaw?.data?.updateSite?.operation?.message || 'An unexpected error occured.')
EVENT_BUS.emit('applyTheme')
}
$q.notify({
type: 'positive',
message: t('admin.theme.saveSuccess')
})
} catch (err) {
$q.notify({
type: 'negative',

@ -126,8 +126,7 @@ q-page.admin-groups
<script setup>
import { cloneDeep, debounce } from 'lodash-es'
import { DateTime } from 'luxon'
import { debounce } from 'es-toolkit/function'
import { useI18n } from 'vue-i18n'
import { useMeta, useQuasar } from 'quasar'
import { onBeforeUnmount, onMounted, reactive, watch } from 'vue'
@ -237,46 +236,46 @@ watch(() => state.currentPage, (newValue) => {
async function load ({ page } = {}) {
state.loading++
$q.loading.show()
const resp = await APOLLO_CLIENT.query({
query: `
query getUsers(
$page: Int
$pageSize: Int
$filter: String
) {
users(
page: $page
pageSize: $pageSize
filter: $filter
) {
total
users {
id
name
email
isSystem
isActive
createdAt
lastLoginAt
}
}
try {
const resp = await API_CLIENT.get('users', {
searchParams: {
...(state.search ? { filter: state.search } : {}),
page: page ?? state.currentPage ?? 1,
limit: state.pageSize ?? 20
}
`,
fetchPolicy: 'network-only',
variables: {
page: page ?? state.currentPage ?? 1,
pageSize: state.pageSize ?? 20,
filter: state.search ?? ''
}
})
state.totalPages = Math.ceil((resp?.data?.users?.total || 1) / state.pageSize)
state.users = cloneDeep(resp?.data?.users?.users)
}).json()
state.totalPages = Math.ceil((resp?.total || 1) / state.pageSize)
state.users = resp?.users ?? []
} catch (err) {
$q.notify({
type: 'negative',
message: t('admin.users.loadFailed'),
caption: err.message
})
}
$q.loading.hide()
state.loading--
}
/** Largest-first. `week` is deliberately absent, so output reads e.g. "21 days ago". */
const RELATIVE_UNITS = [
['year', 31536000],
['month', 2592000],
['day', 86400],
['hour', 3600],
['minute', 60],
['second', 1]
]
const relativeTimeFormat = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
function humanizeDate (val) {
return DateTime.fromISO(val).toRelative()
if (!val) { return '---' }
const seconds = Temporal.Instant.from(val).until(Temporal.Now.instant()).total('seconds')
for (const [unit, secondsPerUnit] of RELATIVE_UNITS) {
if (Math.abs(seconds) >= secondsPerUnit || unit === 'second') {
return relativeTimeFormat.format(-Math.round(seconds / secondsPerUnit), unit)
}
}
}
function formattedDate (val) {
return userStore.formatDateTime(t, val)

@ -18,28 +18,10 @@ export default defineConfig(({ mode }) => {
assetsDir: '_assets',
chunkSizeWarningLimit: 5000,
dynamicImportVarsOptions: {
warnOnError: true,
include: ['!/_blocks/**']
},
outDir: '../assets',
target: 'es2022',
...(mode === 'production') && {
rollupOptions: {
output: {
manualChunks (id) {
if (id.includes('lodash')) {
return 'lodash'
// } else if (id.includes('quasar')) {
// return 'quasar'
} else if (id.includes('pages/Admin')) {
return 'admin'
} else if (id.includes('pages/Profile')) {
return 'profile'
}
}
}
}
}
target: 'es2022'
},
optimizeDeps: {
include: [

Loading…
Cancel
Save