mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 05:54:49 -05:00
refactor: wire most site and users admin views
This commit is contained in:
49 files changed
+4123
-3027
No files matched your search
@@ -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
|
||||
+93
-7
@@ -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'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
+100
-4
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+229
-15
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+542
-17
@@ -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()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
+10
-4
@@ -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,
|
||||
|
||||
+82
-10
@@ -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,
|
||||
|
||||
+395
-2
@@ -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/**",
|
||||
|
||||
Vendored
-14
@@ -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"
|
||||
]
|
||||
}
|
||||
Generated
+833
-1831
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
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
id: adminStore.currentSiteId,
|
||||
patch: {
|
||||
editors: {
|
||||
asciidoc: { isActive: state.config.asciidoc },
|
||||
markdown: { isActive: state.config.markdown },
|
||||
wysiwyg: { isActive: state.config.wysiwyg }
|
||||
}
|
||||
// -> 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 }
|
||||
}
|
||||
}
|
||||
})
|
||||
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')
|
||||
})
|
||||
} else {
|
||||
throw new Error(respRaw?.data?.updateSite?.operation?.message || 'An unexpected error occured.')
|
||||
}).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
|
||||
}
|
||||
})
|
||||
}
|
||||
$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
|
||||
}
|
||||
const resp = await API_CLIENT.put(`sites/${adminStore.currentSiteId}`, {
|
||||
json: {
|
||||
theme: patchTheme
|
||||
}
|
||||
})
|
||||
if (respRaw?.data?.updateSite?.operation?.succeeded) {
|
||||
if (adminStore.currentSiteId === siteStore.id) {
|
||||
siteStore.$patch({
|
||||
theme: patchTheme
|
||||
})
|
||||
EVENT_BUS.emit('applyTheme')
|
||||
}
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: t('admin.theme.saveSuccess')
|
||||
})
|
||||
} else {
|
||||
throw new Error(respRaw?.data?.updateSite?.operation?.message || 'An unexpected error occured.')
|
||||
}).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
|
||||
})
|
||||
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)
|
||||
|
||||
+1
-19
@@ -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: [
|
||||
|
||||
Reference in new issue
Block a user