feat: remaining auth + mail implementation + sample content utility

scarlett
NGPixel 2 weeks ago
parent 59ef5e779d
commit ccb2b702fe
No known key found for this signature in database

@ -31,9 +31,13 @@
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// This can be used to network with other containers or with the host.
"forwardPorts": [3000, 3001, 5432, 8000],
"forwardPorts": [1025, 3000, 3001, 5432, 8000, 8025],
"portsAttributes": {
"1025": {
"label": "Mailpit SMTP",
"onAutoForward": "silent"
},
"3000": {
"label": "Application Backend",
"onAutoForward": "silent"
@ -49,6 +53,10 @@
"8000": {
"label": "PGAdmin",
"onAutoForward": "silent"
},
"8025": {
"label": "Mailpit",
"onAutoForward": "silent"
}
},

@ -37,6 +37,24 @@ services:
POSTGRES_USER: postgres
POSTGRES_DB: postgres
# A mail server that delivers nothing, which is what makes it useful: everything the wiki sends —
# a registration's confirmation link, a password reset, the admin area's test button — lands in a
# web inbox at http://localhost:8025 instead of somebody's mailbox.
#
# Configure it under Admin -> Mail: host `localhost`, port 1025, TLS off, no username or password.
# Sharing the database container's network namespace is what makes `localhost` reach it.
mailpit:
image: axllent/mailpit:latest
restart: unless-stopped
environment:
# -> Nothing here is reachable from outside the container, and a dev instance should not fail a
# send because of a credential nobody has set: any AUTH is accepted, over a plain connection.
- MP_SMTP_AUTH_ACCEPT_ANY=true
- MP_SMTP_AUTH_ALLOW_INSECURE=true
# -> Deliberately no volume: these are test messages, and a mailbox that survives a rebuild is a
# mailbox full of last month's password resets.
network_mode: service:db
pgadmin:
image: dpage/pgadmin4:latest
environment:

@ -35,7 +35,11 @@ The backend is **TypeScript 7**; `frontend/` and `blocks/` are JavaScript. See
`assets/_assets/`. Served by the backend. Don't hand-edit.
- `dev/` — deployment/packaging artifacts: `dev/build/Dockerfile` (production image), `dev/helm/`,
`dev/packer/`, `dev/noto-emoji-build/`.
- `.devcontainer/` — VS Code dev container (app + postgres + pgAdmin via docker-compose).
- `.devcontainer/` — VS Code dev container (app + postgres + pgAdmin + mailpit via docker-compose).
Mailpit is the mail server for development: it accepts everything and delivers nothing, so a
confirmation link or a password reset lands in a web inbox at `http://localhost:8025` rather than a
real mailbox. Point the wiki at it under **Admin → Mail** — host `localhost`, port 1025, TLS off,
no credentials.
- `localazy.json` — translation sync config; locale strings live in `backend/locales/`.
### `backend/`
@ -722,17 +726,15 @@ store; no SVG is ever written into content.
An earlier iteration of 3.x used GraphQL/Apollo. **All of it is deprecated** — there is no GraphQL
server left in `backend/`, and `APOLLO_CLIENT` is not defined as a global, so any call still going
through it throws. `blocks/block-index/` also still imports a `tree.graphql`.
through it throws.
Three files under `frontend/src/` make live `APOLLO_CLIENT` calls, and each needs a REST endpoint
that does not exist yet, so the feature behind it is currently broken:
**One call is left.** `pages/AdminNavigation.vue`'s `save()` sends the navigation tree and its mode
through `APOLLO_CLIENT.mutate`, so saving the navigation is broken until it is ported. Nothing else
under `frontend/src/` references the global. That handler needs more than the endpoint, mind: it also
calls `this.$store.commit(...)` nine times over, and the file is `<script setup>` with no Vuex store
anywhere in the app — so `this` is undefined and every one of those throws too.
| File | Feature |
| ---- | ------- |
| `components/AuthLoginPanel.vue` | self-registration (the `register()` call only — passkey login and 2FA are REST now) |
| `pages/AdminNavigation.vue`, `pages/AdminUtilities.vue` | assorted admin actions |
When touching such a file, port it to the REST API (`API_CLIENT` + the matching `backend/api/` route)
When touching it, port it to the REST API (`API_CLIENT` + the matching `backend/api/` route)
rather than extending the GraphQL code. If the REST endpoint doesn't exist yet, add it under
`backend/api/` following the schema + permissions conventions above — `sites/:siteId/images/:kind`,
which replaced the logo and favicon upload mutations in `AdminGeneral.vue`, is a recent example of

@ -375,6 +375,332 @@ async function routes(app: FastifyInstance) {
}
)
/**
* REGISTER
*
* Self-registration on the login screen, which only the local module offers: everything else that
* creates accounts does it on the way through a successful sign-in at the provider.
*/
app.post<{
Params: { siteId: string }
Body: { strategyId: string; name: string; email: string; password: string }
}>(
'/sites/:siteId/auth/register',
{
config: {
publicAccess: true
},
// -> Public and account-creating; see `helpers/rateLimit.ts`
onRequest: limitAuthAttempts,
schema: {
summary: 'Register a new account',
description:
'Refused unless the strategy is a local one the site offers and has registration turned on. Answers like the login route does: an account that needed no email confirmation is signed in from here, and one that did gets `verifyEmail` instead, with nothing to continue — the link in the email is what finishes it.',
tags: ['Authentication'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
},
body: {
type: 'object',
required: ['strategyId', 'name', 'email', 'password'],
properties: {
strategyId: {
type: 'string',
format: 'uuid'
},
name: {
type: 'string',
minLength: 1,
maxLength: 255
},
email: {
type: 'string',
format: 'email',
maxLength: 255
},
password: {
type: 'string',
minLength: 8,
maxLength: 255
}
}
},
response: {
200: { $ref: 'AuthLoginResult#' }
}
}
},
async (req, reply) => {
try {
if (!/^[^<>"]+$/.test(req.body.name)) {
throw new Error('ERR_INVALID_NAME')
}
const result = await WIKI.models.users.registerUser(
{
siteId: req.params.siteId,
strategyId: req.body.strategyId,
name: req.body.name,
email: req.body.email,
password: req.body.password,
ip: req.ip,
baseUrl: WIKI.models.mail.baseUrl({ req, siteId: req.params.siteId })
},
req
)
if (result.authenticated) {
req.session.authenticated = true
}
return {
ok: true,
...result
}
} catch (err: any) {
if (err.message.startsWith('ERR_')) {
return reply.badRequest(err.message)
}
WIKI.logger.warn(err)
WIKI.models.flags.authDebug(`Registration failed unexpectedly: ${err.message}`)
return reply.badRequest('ERR_REGISTRATION_FAILED')
}
}
)
/**
* CONFIRM AN EMAIL ADDRESS
*
* The other end of the link in a registration email but not the link itself, which lands on the
* login screen and puts a button in front of the reader. **This has to be a POST that somebody
* pressed**, never a GET the link performs: Outlook's Safe Links and the scanners like it fetch
* every URL in a message before it is delivered, and a GET that confirmed the address would be
* spent by the scanner, leaving the real click with a token that has already been used. A form
* submission from the page is not something a link scanner makes.
*
* Nobody is signed in by it either: the browser reading the mail is not necessarily the one that
* registered, and the password is still needed.
*/
app.post<{ Params: { siteId: string }; Body: { token: string } }>(
'/sites/:siteId/auth/verifyEmail',
{
config: {
publicAccess: true
},
// -> The token is the whole of the secret; see `helpers/rateLimit.ts`
onRequest: limitAuthAttempts,
schema: {
summary: 'Confirm an email address',
description:
'Consumes the token from a registration email and marks the account verified, so it works once. Deliberately not reachable by GET — see the route comment.',
tags: ['Authentication'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
},
body: {
type: 'object',
required: ['token'],
properties: {
token: {
type: 'string',
minLength: 1,
maxLength: 255
}
}
},
response: {
200: {
description: 'The address was confirmed',
type: 'object',
properties: {
ok: {
type: 'boolean'
}
}
}
}
}
},
async (req, reply) => {
try {
await WIKI.models.users.verifyUserEmail(req.body.token)
return { ok: true }
} catch (err: any) {
WIKI.models.flags.authDebug(`Email confirmation refused: ${err.message}`)
if (err.message.startsWith('ERR_')) {
return reply.badRequest(err.message)
}
WIKI.logger.warn(err)
return reply.badRequest('ERR_INVALID_VALIDATION_TOKEN')
}
}
)
/**
* REQUEST A PASSWORD RESET
*
* Answers the same way whether or not the address belongs to anybody see `requestPasswordReset`
* for why. What it does report is the two things that are about this wiki rather than about a user:
* a strategy that does not offer resets, and an instance with no mail server configured.
*/
app.post<{ Params: { siteId: string }; Body: { strategyId: string; email: string } }>(
'/sites/:siteId/auth/forgotPassword',
{
config: {
publicAccess: true
},
// -> Public, and sends mail on demand; see `helpers/rateLimit.ts`
onRequest: limitAuthAttempts,
schema: {
summary: 'Request a password reset link',
description:
'Succeeds for any address, registered or not: a public form that answered differently would be a way of finding out who has an account here.',
tags: ['Authentication'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
},
body: {
type: 'object',
required: ['strategyId', 'email'],
properties: {
strategyId: {
type: 'string',
format: 'uuid'
},
email: {
type: 'string',
format: 'email',
maxLength: 255
}
}
},
response: {
200: {
description: 'The request was accepted',
type: 'object',
properties: {
ok: {
type: 'boolean'
}
}
}
}
}
},
async (req, reply) => {
try {
await WIKI.models.users.requestPasswordReset({
siteId: req.params.siteId,
strategyId: req.body.strategyId,
email: req.body.email,
ip: req.ip,
baseUrl: WIKI.models.mail.baseUrl({ req, siteId: req.params.siteId })
})
return { ok: true }
} catch (err: any) {
if (err.message.startsWith('ERR_')) {
return reply.badRequest(err.message)
}
WIKI.logger.warn(err)
WIKI.models.flags.authDebug(`Password reset request failed unexpectedly: ${err.message}`)
return reply.badRequest('ERR_FORGOT_PASSWORD_FAILED')
}
}
)
/**
* SET A NEW PASSWORD FROM A RESET LINK
*
* The token stands for the mailbox rather than for a half-finished login, so unlike the
* change-password route above this one signs nobody in: what comes next is the login screen.
*/
app.post<{ Params: { siteId: string }; Body: { token: string; newPassword: string } }>(
'/sites/:siteId/auth/resetPassword',
{
config: {
publicAccess: true
},
// -> The token is guessable in principle; see `helpers/rateLimit.ts`
onRequest: limitAuthAttempts,
schema: {
summary: 'Set a new password from a reset link',
tags: ['Authentication'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
},
body: {
type: 'object',
required: ['token', 'newPassword'],
properties: {
token: {
type: 'string',
minLength: 1,
maxLength: 255
},
newPassword: {
type: 'string',
minLength: 8,
maxLength: 255
}
}
},
response: {
200: {
description: 'The password was changed',
type: 'object',
properties: {
ok: {
type: 'boolean'
}
}
}
}
}
},
async (req, reply) => {
try {
await WIKI.models.users.resetPassword({
token: req.body.token,
newPassword: req.body.newPassword
})
return { ok: true }
} catch (err: any) {
if (err.message.startsWith('ERR_')) {
WIKI.models.flags.authDebug(`Password reset rejected: ${err.message}`)
return reply.badRequest(err.message)
}
WIKI.logger.warn(err)
WIKI.models.flags.authDebug(`Password reset failed unexpectedly: ${err.message}`)
return reply.badRequest('ERR_CHANGE_PASSWORD_FAILED')
}
}
)
/**
* SUBMIT A 2FA CODE
*

@ -139,6 +139,83 @@ async function routes(app: FastifyInstance) {
}
}
)
/**
* SEND A TEST EMAIL
*
* The one thing that says whether the settings above actually work, since nothing probes the SMTP
* server on its own a wiki finds out its mail is misconfigured when somebody cannot reset their
* password otherwise. Sent through the same transport every other mail goes through, so a failure
* here is the failure they would have hit.
*/
app.post<{ Body: { recipient: string } }>(
'/test',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Send a test email',
description:
'Uses the stored configuration as it currently stands, so save any changes first. The reply waits for the SMTP server to accept the message, and carries its complaint verbatim when it does not.',
tags: ['Mail'],
body: {
type: 'object',
required: ['recipient'],
properties: {
recipient: {
type: 'string',
format: 'email',
maxLength: 255
}
}
},
response: {
200: {
description: 'The test email was accepted by the mail server',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
if (!WIKI.models.mail.isConfigured) {
return reply.badRequest(
'Mail is not configured: an SMTP host and a sender address are required.'
)
}
// -> Whichever site this admin area is being used on: all it decides is the name in the mail
const siteId =
(await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname }))?.id ?? ''
try {
await WIKI.models.mail.send({
siteId,
to: req.body.recipient,
template: 'test',
data: {
baseUrl: WIKI.models.mail.baseUrl({ req, siteId })
}
})
return {
ok: true,
message: 'Test email sent successfully.'
}
} catch (err: any) {
WIKI.logger.warn(`Test email to <${req.body.recipient}> failed: ${err.message}`)
// -> The mail server's own words: a rejected sender, a refused relay and a bad password all
// read differently, and the administrator is the person who can act on the difference
return reply.badRequest(err.message)
}
}
)
}
export default routes

@ -17,9 +17,9 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
},
nextAction: {
type: 'string',
enum: ['redirect', 'changePassword', 'provideTfa', 'setupTfa'],
enum: ['redirect', 'changePassword', 'provideTfa', 'setupTfa', 'verifyEmail'],
description:
'What the client has to do to finish. Anything other than `redirect` means the attempt is not a login yet and has to be continued with `continuationToken`.'
'What the client has to do to finish. Anything other than `redirect` means the attempt is not a login yet. `changePassword`, `provideTfa` and `setupTfa` are continued with `continuationToken`; `verifyEmail` — only ever from registration — is not continued here at all, since the link in the email is what finishes it.'
},
continuationToken: {
type: 'string',
@ -155,7 +155,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
registration: {
type: 'boolean',
description:
'Whether an account is created for somebody signing in for the first time. Enforced for the providers that sign users in elsewhere (OpenID Connect, Google, GitHub); the local module has a registration flow of its own.'
'Whether an account is created for somebody who has none yet. For the providers that sign users in elsewhere (OpenID Connect, Google, GitHub) that happens on the way through a first successful sign-in; for the local module it is what puts the registration form on the login screen.'
},
allowedEmailRegex: {
type: 'string',
@ -170,7 +170,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
format: 'uuid'
},
description:
'Groups a self-registered user would join. The guests group is refused. Stored but not enforced, as above.'
'Groups a self-registered user joins, whether they registered on the login screen or arrived through a provider. The guests group is refused.'
},
config: {
type: 'object',

@ -15,6 +15,16 @@ import { purgeTimeframes } from '../models/pageHistory.ts'
import type { PurgeTimeframe } from '../models/pageHistory.ts'
import type { FastifyInstance } from 'fastify'
/**
* The tag the admin area's Generate Sample Content puts on every page it writes, and the only thing
* Purge Sample Content goes looking for.
*
* **Also written in `frontend/src/helpers/sampleContent.js`**, which is what does the generating
* the three workspaces share no package, so the two have to be kept in step by hand. Changing one
* without the other leaves content that nothing will clean up.
*/
const SAMPLE_CONTENT_TAG = 'test'
/**
* Every instance connected to this database, with how it is using the connection pool.
*
@ -1213,6 +1223,74 @@ async function routes(app: FastifyInstance) {
}
)
/**
* PURGE SAMPLE CONTENT
*
* The other half of the admin area's Generate Sample Content, which is a development convenience:
* it fills a fresh instance with pages to look at instead of making somebody write dummy content to
* test against. Every page it writes carries the `test` tag, and this deletes exactly those.
*
* The tag is the whole of the contract, which is what makes this usable on content nothing here
* generated tag a page `test` by hand and this takes it too. Said plainly in the description
* rather than left as a surprise.
*/
app.post<{ Body: { siteId: string } }>(
'/sample-content/purge',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Delete every page tagged `test` on a site',
description:
"The counterpart to the admin area's Generate Sample Content, which tags everything it writes `test`. Membership of that tag is the only thing consulted, so a page tagged by hand is deleted too. Folders are left standing: they carry no tags, and one somebody made themselves must not go because a sample page was filed in it. Each page is deleted the way the file manager deletes one — its history records the deletion, and its copy on every storage target goes with it.",
tags: ['System'],
body: {
type: 'object',
required: ['siteId'],
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
}
},
response: {
200: {
description: 'Sample content purged successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
count: {
type: 'number',
description: 'Pages deleted.'
}
}
}
}
}
},
async (req, reply) => {
if (!WIKI.sites[req.body.siteId]) {
return reply.badRequest('This site does not exist.')
}
const count = await WIKI.models.pages.deletePagesByTag(req.body.siteId, SAMPLE_CONTENT_TAG, {
id: req.session.user!.id,
permissions: req.session.permissions ?? []
})
return {
ok: true,
message: `Purged ${count} page(s) tagged ${SAMPLE_CONTENT_TAG}.`,
count
}
}
)
/**
* CHECK FOR UPDATE
*/

@ -1286,7 +1286,7 @@ async function routes(app: FastifyInstance) {
schema: {
summary: 'Create a new user',
description:
'Creates a user authenticated against the local strategy. `sendWelcomeEmail` is accepted but not yet supported, as the server has no mail transport.',
'Creates a user authenticated against the local strategy. With `sendWelcomeEmail` the new address is told the account exists and where to sign in — the account is created either way, so a mail that could not be sent is reported in the reply rather than failing the request.',
tags: ['Users'],
body: {
type: 'object',
@ -1350,6 +1350,11 @@ async function routes(app: FastifyInstance) {
id: {
type: 'string',
format: 'uuid'
},
welcomeEmailError: {
type: 'string',
description:
'Only when `sendWelcomeEmail` was asked for and the mail server refused it — what it said. The account was still created.'
}
}
}
@ -1363,11 +1368,10 @@ async function routes(app: FastifyInstance) {
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) {
if (req.body.sendWelcomeEmail && !WIKI.models.mail.isConfigured) {
throw new CustomError(
'userCreateWelcomeEmailUnavailable',
'Sending a welcome email is not supported yet, as mail delivery is not implemented.'
'No SMTP server is configured, so no welcome email can be sent.'
)
}
@ -1379,6 +1383,28 @@ async function routes(app: FastifyInstance) {
groups: req.body.groups ?? [],
mustChangePassword: req.body.mustChangePassword ?? false
})
/*
After the account, and never allowed to undo it: an administrator asked for a user and got
one, and a mail server that would not take the message does not change that. The failure is
reported in the same reply instead, since the button that sends it again is one screen away.
*/
if (req.body.sendWelcomeEmail) {
try {
await WIKI.models.users.sendWelcomeEmail({
userId: id,
siteId: req.body.sendWelcomeEmailFromSiteId,
req
})
} catch (err: any) {
WIKI.logger.warn(`Welcome email for new user ${id} failed: ${err.message}`)
return {
ok: true,
message: 'User created successfully.',
id,
welcomeEmailError: err.message
}
}
}
return {
ok: true,
message: 'User created successfully.',
@ -1664,6 +1690,85 @@ async function routes(app: FastifyInstance) {
}
)
/**
* SEND A WELCOME EMAIL
*
* The same mail `POST /users` offers to send on create, available afterwards for an account
* created before mail was configured, or one whose owner never got the first one.
*/
app.post<{ Params: { userId: string }; Body: { siteId?: string } }>(
'/:userId/send-welcome-email',
{
config: {
permissions: ['manage:users']
},
schema: {
summary: 'Send a welcome email to a user',
description:
"Tells the account's address that it exists and where to sign in. Carries no password and no confirmation link: an account an administrator created is verified already.",
tags: ['Users'],
params: {
type: 'object',
properties: {
userId: {
type: 'string',
format: 'uuid'
}
},
required: ['userId']
},
body: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid',
description:
'Which site to welcome them to, i.e. whose name and address the mail carries. Defaults to the one this request was addressed to.'
}
}
},
response: {
200: {
description: 'The welcome email was accepted by the mail server',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
if (!WIKI.models.mail.isConfigured) {
return reply.badRequest('No SMTP server is configured, so no welcome email can be sent.')
}
try {
await WIKI.models.users.sendWelcomeEmail({
userId: req.params.userId,
siteId: req.body?.siteId,
req
})
return {
ok: true,
message: 'Welcome email sent successfully.'
}
} catch (err: any) {
if (err.message === 'ERR_INVALID_USER') {
return reply.notFound('User does not exist.')
}
WIKI.logger.warn(`Welcome email for user ${req.params.userId} failed: ${err.message}`)
// -> The mail server's own words, as with the test mail: what is wrong is on its end
return reply.badRequest(err.message)
}
}
)
app.delete<{ Params: { userId: string } }>(
'/:userId',
{

@ -57,9 +57,22 @@ defaults:
api:
isEnabled: false
mail:
senderName: ''
senderEmail: ''
# Where links in emails point. Left empty, the host the request came in on is used instead —
# which is right for a small instance and wrong behind a proxy or on a private address.
defaultBaseURL: ''
host: ''
port: 465
name: ''
secure: true
verifySSL: true
user: ''
pass: ''
useDKIM: false
dkimDomainName: ''
dkimKeySelector: ''
dkimPrivateKey: ''
metrics:
isEnabled: false
auth:

@ -581,8 +581,8 @@
"admin.mail.dkimUse": "Use DKIM",
"admin.mail.dkimUseHint": "Should DKIM be used when sending emails.",
"admin.mail.saveSuccess": "Configuration saved successfully.",
"admin.mail.sendTestFailed": "The test email could not be sent.",
"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",
@ -610,6 +610,7 @@
"admin.mail.testHint": "Send a test email to ensure your SMTP configuration is working.",
"admin.mail.testRecipient": "Recipient Email Address",
"admin.mail.testRecipientHint": "Email address that should receive the test email.",
"admin.mail.testRecipientMissing": "Enter the address the test email should go to.",
"admin.mail.testSend": "Send Email",
"admin.mail.title": "Mail",
"admin.mcp.disableButton": "Disable MCP",
@ -1132,9 +1133,11 @@
"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.sendWelcomeEmailAltHint": "Send the user an email telling them the account exists and where to sign in.",
"admin.users.sendWelcomeEmailFailed": "The welcome email could not be sent.",
"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.sendWelcomeEmailHint": "An email will be sent to the user telling them the account exists and where to sign in. It does not contain the password.",
"admin.users.sendWelcomeEmailSuccess": "Welcome email sent successfully.",
"admin.users.subtitle": "Manage Users",
"admin.users.systemUser": "System User",
"admin.users.tfa": "Two Factor Authentication (2FA)",
@ -1161,6 +1164,7 @@
"admin.users.updateUser": "Update User",
"admin.users.userActivateSuccess": "User has been activated successfully.",
"admin.users.userAlreadyAssignedToGroup": "User is already assigned to this group!",
"admin.users.userCreateWelcomeEmailUnavailable": "No mail server is configured, so no welcome email can be sent.",
"admin.users.userDeactivateSuccess": "User deactivated successfully.",
"admin.users.userTFADisableSuccess": "2FA was disabled successfully.",
"admin.users.userTFAEnableSuccess": "2FA was enabled successfully.",
@ -1186,6 +1190,13 @@
"admin.utilities.flushCacheFailed": "Failed to flush the cache.",
"admin.utilities.flushCacheHint": "Files, icons and site settings are cached for better performance. Flushing forces everything to be fetched from the database again, on every instance.",
"admin.utilities.flushCacheSuccess": "The cache has been flushed.",
"admin.utilities.generateSample": "Generate Sample Content",
"admin.utilities.generateSampleConfirm": "Write {count} sample pages to {site}?",
"admin.utilities.generateSampleConfirmWarn": "They are filed under /sample and every one is tagged \"{tag}\", which is what Purge Sample Content deletes. A page already at one of those paths is left alone.",
"admin.utilities.generateSampleFailed": "The sample content could not be generated.",
"admin.utilities.generateSampleHint": "Fill this site with pages covering every kind of formatting and every block, in a folder tree several levels deep. Mostly for development / debugging purposes.",
"admin.utilities.generateSamplePartial": "No sample pages could be written. | Wrote 1 sample page; the rest could not be written. | Wrote {count} sample pages; the rest could not be written.",
"admin.utilities.generateSampleSuccess": "No sample pages were written. | Wrote 1 sample page. | Wrote {count} sample pages.",
"admin.utilities.graphEndpointSubtitle": "Change the GraphQL endpoint for Wiki.js",
"admin.utilities.graphEndpointTitle": "GraphQL Endpoint",
"admin.utilities.import": "Import",
@ -1216,6 +1227,12 @@
"admin.utilities.purgeRevokedKeysFailed": "Failed to purge the revoked API keys.",
"admin.utilities.purgeRevokedKeysHint": "Permanently delete the API keys that have been revoked. Invalidated keys are kept.",
"admin.utilities.purgeRevokedKeysSuccess": "No revoked API key to purge. | 1 revoked API key deleted. | {count} revoked API keys deleted.",
"admin.utilities.purgeSample": "Purge Sample Content",
"admin.utilities.purgeSampleConfirm": "Delete every page tagged \"{tag}\" on {site}?",
"admin.utilities.purgeSampleConfirmWarn": "The tag is the only thing checked, so a page you tagged \"{tag}\" yourself is deleted too. Folders are left in place. This cannot be undone.",
"admin.utilities.purgeSampleFailed": "The sample content could not be deleted.",
"admin.utilities.purgeSampleHint": "Delete every page on this site tagged \"test\", which is what Generate Sample Content tags the pages it writes.",
"admin.utilities.purgeSampleSuccess": "There was no sample content to delete. | Deleted 1 page. | Deleted {count} pages.",
"admin.utilities.scanPageProblems": "Scan for Page Problems",
"admin.utilities.scanPageProblemsHint": "Scan all pages for invalid, missing or corrupted data.",
"admin.utilities.subtitle": "Maintenance and miscellaneous tools",
@ -1353,6 +1370,8 @@
"auth.registerSuccess": "Account created successfully!",
"auth.registerTitle": "Create an account",
"auth.registering": "Creating account...",
"auth.resetPwd.instructions": "Choose the new password for your account:",
"auth.resetPwd.success": "Your password has been changed. You can now log in with it.",
"auth.selectAuthProvider": "Sign in with",
"auth.sendResetPassword": "Reset Password",
"auth.signingIn": "Signing In...",
@ -1369,6 +1388,10 @@
"auth.tfaSetupSuccess": "2FA enabled successfully on your account.",
"auth.tfaSetupTitle": "Your administrator has required Two-Factor Authentication (2FA) to be enabled on your account.",
"auth.tfaSetupVerifying": "Verifying...",
"auth.verifyEmail.instructions": "Confirm that this email address is yours to activate your account:",
"auth.verifyEmail.loading": "Confirming your email address...",
"auth.verifyEmail.proceed": "Confirm Email Address",
"auth.verifyEmail.success": "Your email address has been confirmed. You can now log in.",
"common.actions.activate": "Activate",
"common.actions.add": "Add",
"common.actions.apply": "Apply",
@ -1922,18 +1945,24 @@
"editor.unsaved.body": "You have unsaved changes. Are you sure you want to leave the editor and discard any modifications you made since the last save?",
"editor.unsaved.title": "Discard Unsaved Changes?",
"editor.unsavedWarning": "You have unsaved edits. Are you sure you want to leave the editor?",
"error.ERR_ACCOUNT_ALREADY_EXISTS": "An account already exists for that email address.",
"error.ERR_CHANGE_PASSWORD_FAILED": "The password could not be changed.",
"error.ERR_EMAIL_NOT_ALLOWED": "That email address is not allowed to sign in through this provider.",
"error.ERR_EMAIL_NOT_VERIFIED": "The provider has not verified that email address.",
"error.ERR_EXPIRED_VALIDATION_TOKEN": "This request has expired. Please start over.",
"error.ERR_FORGOT_PASSWORD_DISABLED": "Password resets are turned off for this login method.",
"error.ERR_FORGOT_PASSWORD_FAILED": "The password reset could not be requested.",
"error.ERR_INACTIVE_USER": "This account is deactivated.",
"error.ERR_INCORRECT_CURRENT_PASSWORD": "The current password is incorrect.",
"error.ERR_INVALID_NAME": "Name is invalid.",
"error.ERR_INVALID_STRATEGY": "This authentication method cannot be used here.",
"error.ERR_INVALID_USER": "This account no longer exists.",
"error.ERR_INVALID_VALIDATION_TOKEN": "This request is no longer valid. Please start over.",
"error.ERR_LOGIN_EXPIRED": "That sign-in took too long or was started somewhere else. Please try again.",
"error.ERR_LOGIN_FAILED": "The email or password is invalid.",
"error.ERR_LOGIN_RESTRICTED": "Password login is turned off for this account.",
"error.ERR_MAIL_NOT_CONFIGURED": "This wiki has no mail server configured, so it cannot send you an email. Ask an administrator.",
"error.ERR_MAIL_SEND_FAILED": "The email could not be sent. Please try again, or ask an administrator.",
"error.ERR_NO_AUTHORIZATION_CODE": "The provider did not return an authorization code.",
"error.ERR_NO_EMAIL_FROM_PROVIDER": "The provider did not give an email address to identify you by.",
"error.ERR_NO_ID_TOKEN": "The provider did not return an identity token.",
@ -1951,6 +1980,7 @@
"error.ERR_PK_VERIFICATION_FAILED": "This passkey could not be verified.",
"error.ERR_PROVIDER_REQUEST_FAILED": "The provider could not be reached. Please try again.",
"error.ERR_REGISTRATION_DISABLED": "This provider does not create new accounts. Ask an administrator to invite you first.",
"error.ERR_REGISTRATION_FAILED": "The account could not be created.",
"error.ERR_STRATEGY_MISCONFIGURED": "This provider is not fully configured. An administrator needs to finish setting it up.",
"error.ERR_TFA_ALREADY_ACTIVE": "2FA is already enabled on this account. Turn it off before setting it up again.",
"error.ERR_TFA_ENFORCED": "2FA cannot be turned off, as it is required on this account.",

@ -148,6 +148,33 @@ class Authentication {
return (await this.getActiveStrategies()).find((stg) => stg.id === id) ?? null
}
/**
* A strategy as it is offered on one site, or null when it is not offered there at all.
*
* The self-service flows registering, and asking for a password reset resolve their strategy
* through this rather than merely checking that the ID names one, because otherwise a strategy an
* administrator has taken off a site would still create accounts on it for anyone who kept the ID.
*
* Being on the site is what is asked, not being *visible* on it: `isVisible` decides whether the
* login screen offers a strategy, and a wiki that hides its local login while still allowing it
* must not thereby lose password resets. That matches `login()`, which does not consult visibility
* either and which resolves through `WIKI.auth.strategies` rather than through here, because it
* needs the live module instance instead of the stored row.
*/
async getSiteStrategy(siteId: string, strategyId: string): Promise<AuthStrategy | null> {
const site = await WIKI.models.sites.getSiteById({ id: siteId })
if (!site) {
return null
}
const strategy = await this.getStrategyById(strategyId)
if (!strategy?.isEnabled) {
return null
}
// -> A site created before it had strategies configured has no list at all
const configured = (site.config.authStrategies ?? []) as { id: string }[]
return configured.some((s) => s.id === strategyId) ? strategy : null
}
/**
* Merge incoming config values onto the ones already stored, keeping only what the module declares.
*

@ -10,6 +10,7 @@ import { hooks } from './hooks.ts'
import { icons } from './icons.ts'
import { jobs } from './jobs.ts'
import { locales } from './locales.ts'
import { mail } from './mail.ts'
import { navigation } from './navigation.ts'
import { pageHistory } from './pageHistory.ts'
import { pages } from './pages.ts'
@ -40,6 +41,7 @@ export default {
icons,
jobs,
locales,
mail,
navigation,
pageHistory,
pages,

@ -0,0 +1,429 @@
import { createTransport } from 'nodemailer'
import type { Transporter } from 'nodemailer'
/**
* The templates this wiki sends, and what each one needs.
*
* Two of them are the ones the admin area names under Mail Templates; `test` is the button beside
* them. Held as literals rather than rows in a table because nothing sends a mail this wiki did not
* ask it to a template is part of the flow that uses it, and a flow that gained one would have to
* gain code here anyway.
*/
export interface MailTemplateData {
welcome: {
/** Who the account was created for, as they typed it. */
name: string
/** Where the site the account was created on lives, without a trailing slash. */
baseUrl: string
/**
* Where to go to confirm the address, when it has to be confirmed at all. Absent on a site whose
* local strategy does not validate addresses, where the account is usable as soon as it is made.
*
* A page that asks, not a link that acts: fetching it confirms nothing, which is what keeps the
* mail scanners that follow every link in a message from spending the token before the reader
* does.
*/
verifyUrl?: string
}
resetPwd: {
name: string
baseUrl: string
/** Where to choose the new password. Stands for the request until it is used or expires. */
resetUrl: string
}
test: {
baseUrl: string
}
}
/** A template key, i.e. one of the keys of `MailTemplateData`. */
export type MailTemplate = keyof MailTemplateData
/** What a rendered template is: a subject line and the two bodies every mail carries. */
interface RenderedMail {
subject: string
text: string
html: string
}
/**
* The SMTP settings, as they are stored under the `mail` key of the settings table.
*
* Everything here is what an administrator typed in the admin area's Mail page, which is also the
* only thing that writes it see `api/mail.ts`.
*/
interface MailConfig {
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
}
/** One outgoing mail, as the models ask for it. */
export interface MailRequest<K extends MailTemplate = MailTemplate> {
/** The site the mail is about, which is what names the wiki in it. */
siteId: string
to: string
template: K
data: MailTemplateData[K]
}
/**
* Take a value out of the template language it is being put into.
*
* Every substitution below is a name somebody typed or a URL built from a hostname, so all of it goes
* through here on the way into the HTML body. The text body needs none of it.
*/
function escapeHtml(str: string): string {
return str
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
}
/**
* The HTML body every mail shares: a heading, some paragraphs, at most one button.
*
* Written as a table with inline styles and no external anything, which is what a mail client will
* actually render the stylesheet, the web font and the background image a page would use are all
* either stripped or blocked by the ones people read mail in.
*/
function htmlShell({
title,
body,
action,
footer
}: {
title: string
/** Paragraphs, already escaped. */
body: string[]
action?: { label: string; url: string }
footer: string
}): string {
const paragraphs = body
.map((p) => `<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:#37474f;">${p}</p>`)
.join('')
const button = action
? `<p style="margin:0 0 16px;"><a href="${escapeHtml(action.url)}" style="display:inline-block;padding:12px 24px;border-radius:4px;background:#1976d2;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;">${escapeHtml(action.label)}</a></p>` +
// -> The same link in full, for the client that will not render the button and for the reader
// who wants to see where it goes before following it
`<p style="margin:0 0 16px;font-size:12px;line-height:1.6;color:#78909c;word-break:break-all;">${escapeHtml(action.url)}</p>`
: ''
return [
'<!DOCTYPE html>',
'<html><body style="margin:0;padding:24px;background:#eceff1;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Helvetica,Arial,sans-serif;">',
'<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="max-width:600px;margin:0 auto;background:#ffffff;border-radius:6px;">',
'<tr><td style="padding:32px;">',
`<h1 style="margin:0 0 24px;font-size:20px;line-height:1.4;color:#263238;">${escapeHtml(title)}</h1>`,
paragraphs,
button,
`<p style="margin:24px 0 0;padding-top:16px;border-top:1px solid #eceff1;font-size:12px;line-height:1.6;color:#90a4ae;">${escapeHtml(footer)}</p>`,
'</td></tr></table></body></html>'
].join('')
}
/**
* Mail model
*
* The one way anything in this wiki sends an email, and the only place nodemailer is used. Three
* flows need it confirming an address at registration, resetting a forgotten password, and the
* admin area's test button and all three go through `send()`.
*
* **A wiki with no SMTP settings is the normal case.** Plenty of instances never configure one, so
* nothing here throws on its own: `isConfigured` is what a caller asks first, and what decides
* whether a flow that needs mail is offered at all. `send()` refuses with `ERR_MAIL_NOT_CONFIGURED`
* rather than failing obscurely inside the transport, so a flow that got that far says something
* an administrator can act on.
*
* The transport is built once and kept, and rebuilt when the settings behind it change
* `configFingerprint()` is how that is noticed, rather than an event, because the settings can be
* changed on another instance in an HA set and this one would never hear about it.
*/
class Mail {
private transporter: Transporter | null = null
private fingerprint = ''
/** The stored settings, whatever state they are in. */
private get config(): MailConfig {
return (WIKI.config.mail ?? {}) as MailConfig
}
/**
* Whether mail can be sent at all.
*
* A host and a sender address, which are the two things no default can stand in for. Everything
* else has one: a port, whether to use TLS, and credentials that plenty of relays do not want.
*/
get isConfigured(): boolean {
const conf = this.config
return Boolean(conf.host?.trim() && conf.senderEmail?.trim())
}
/**
* What the current transport was built from. A change here is what invalidates it.
*/
private configFingerprint(): string {
const conf = this.config
return JSON.stringify([
conf.host,
conf.port,
conf.name,
conf.secure,
conf.verifySSL,
conf.user,
conf.pass,
conf.useDKIM,
conf.dkimDomainName,
conf.dkimKeySelector,
conf.dkimPrivateKey
])
}
private getTransporter(): Transporter {
const fingerprint = this.configFingerprint()
if (this.transporter && fingerprint === this.fingerprint) {
return this.transporter
}
this.transporter?.close?.()
const conf = this.config
this.transporter = createTransport({
host: conf.host,
port: conf.port ?? 465,
secure: conf.secure ?? true,
// -> The name this client identifies itself as in EHLO. Left off, nodemailer sends the machine
// hostname, which is what most relays expect.
...(conf.name?.trim() && { name: conf.name.trim() }),
// -> No credentials at all rather than empty ones: a relay that authenticates by IP address
// refuses an empty AUTH instead of skipping it.
...(conf.user?.trim() && {
auth: {
user: conf.user.trim(),
pass: conf.pass ?? ''
}
}),
tls: {
rejectUnauthorized: conf.verifySSL !== false
},
...(conf.useDKIM &&
conf.dkimPrivateKey?.trim() && {
dkim: {
domainName: conf.dkimDomainName ?? '',
keySelector: conf.dkimKeySelector ?? '',
privateKey: conf.dkimPrivateKey
}
})
})
this.fingerprint = fingerprint
return this.transporter
}
/**
* Where links in emails point, without a trailing slash.
*
* Three answers, in the order they are preferred:
*
* 1. The configured base URL, which is the only one an administrator has actually vouched for. An
* instance behind a proxy, on a private address, or answering to several hostnames cannot be
* trusted to describe itself to somebody reading a mail somewhere else.
* 2. The site's own hostname, which is what the account is on and which is not necessarily the
* host the request came in on: an administrator creating an account for another site is doing
* exactly that. Skipped for the wildcard site, which names no host.
* 3. What the request was addressed to, which is right often enough that a small instance never has
* to configure anything.
*
* @param req The request that triggered the mail, when there is one
* @param siteId The site the mail is about, when it is about one
*/
baseUrl({
req,
siteId
}: { req?: { protocol: string; host: string }; siteId?: string } = {}): string {
const configured = this.config.defaultBaseURL?.trim()
if (configured) {
return configured.replace(/\/+$/, '')
}
const hostname = siteId ? WIKI.sites[siteId]?.hostname : null
if (hostname && hostname !== '*') {
// -> The scheme the caller was reached by, since the hostname alone does not carry one
return `${req?.protocol ?? 'https'}://${hostname}`
}
if (req) {
return `${req.protocol}://${req.host}`
}
return ''
}
/**
* What to call this wiki in a mail. Per site, since that is what the reader was looking at.
*/
private siteName(siteId: string): string {
return WIKI.sites[siteId]?.config?.title || 'Wiki.js'
}
/**
* Render one of the templates.
*
* Both bodies are built from the same values: the text one is what a client that will not render
* HTML shows, and is also what keeps the mail out of a spam folder that scores HTML-only mail.
*/
private render<K extends MailTemplate>(
siteName: string,
template: K,
data: MailTemplateData[K]
): RenderedMail {
switch (template) {
case 'welcome': {
const d = data as MailTemplateData['welcome']
const footer = `You are receiving this because an account was created for this address on ${siteName}.`
if (d.verifyUrl) {
return {
subject: `Confirm your email address — ${siteName}`,
text: [
`Hi ${d.name},`,
'',
`An account was created for this address on ${siteName}. Confirm that it is yours to finish signing up:`,
'',
d.verifyUrl,
'',
'This link is valid for 24 hours. If you did not create this account, you can ignore this message.',
'',
footer
].join('\n'),
html: htmlShell({
title: 'Confirm your email address',
body: [
`Hi ${escapeHtml(d.name)},`,
`An account was created for this address on ${escapeHtml(siteName)}. Confirm that it is yours to finish signing up.`,
'This link is valid for 24 hours. If you did not create this account, you can ignore this message.'
],
action: { label: 'Confirm my email address', url: d.verifyUrl },
footer
})
}
}
return {
subject: `Welcome to ${siteName}`,
text: [
`Hi ${d.name},`,
'',
`Your account on ${siteName} is ready. You can sign in at any time:`,
'',
`${d.baseUrl}/login`,
'',
footer
].join('\n'),
html: htmlShell({
title: `Welcome to ${escapeHtml(siteName)}`,
body: [
`Hi ${escapeHtml(d.name)},`,
'Your account is ready. You can sign in at any time.'
],
action: { label: 'Go to the wiki', url: `${d.baseUrl}/login` },
footer
})
}
}
case 'resetPwd': {
const d = data as MailTemplateData['resetPwd']
const footer = `You are receiving this because a password reset was requested for this address on ${siteName}.`
return {
subject: `Reset your password — ${siteName}`,
text: [
`Hi ${d.name},`,
'',
`Somebody asked to reset the password for your account on ${siteName}. Choose a new one here:`,
'',
d.resetUrl,
'',
'This link is valid for 24 hours and can only be used once. If you did not ask for this, nothing has changed and you can ignore this message.',
'',
footer
].join('\n'),
html: htmlShell({
title: 'Reset your password',
body: [
`Hi ${escapeHtml(d.name)},`,
`Somebody asked to reset the password for your account on ${escapeHtml(siteName)}.`,
'This link is valid for 24 hours and can only be used once. If you did not ask for this, nothing has changed and you can ignore this message.'
],
action: { label: 'Choose a new password', url: d.resetUrl },
footer
})
}
}
default: {
const d = data as MailTemplateData['test']
const footer =
'You are receiving this because somebody sent a test email from the Wiki.js admin area.'
return {
subject: `Test email — ${siteName}`,
text: [
'This is a test email.',
'',
`If you are reading it, ${siteName} can send mail through the SMTP server it is configured with.`,
'',
d.baseUrl,
'',
footer
].join('\n'),
html: htmlShell({
title: 'This is a test email',
body: [
`If you are reading it, ${escapeHtml(siteName)} can send mail through the SMTP server it is configured with.`
],
footer
})
}
}
}
}
/**
* Send one mail, and wait for the relay to have taken it.
*
* Waiting is deliberate: every caller has something to tell the user about the result a
* registration that says to go and check, a reset that says the same, a test button whose entire
* purpose is the answer and a queued send would have to report success before it knew.
*
* @throws `ERR_MAIL_NOT_CONFIGURED` when there is no SMTP server to send through, and whatever
* nodemailer raises for a send that was attempted and failed
*/
async send<K extends MailTemplate>({
siteId,
to,
template,
data
}: MailRequest<K>): Promise<void> {
if (!this.isConfigured) {
throw new Error('ERR_MAIL_NOT_CONFIGURED')
}
const conf = this.config
const siteName = this.siteName(siteId)
const { subject, text, html } = this.render(siteName, template, data)
WIKI.logger.debug(`Sending ${template} email to <${to}>...`)
await this.getTransporter().sendMail({
from: {
name: conf.senderName?.trim() || siteName,
address: conf.senderEmail!.trim()
},
to,
subject,
text,
html
})
WIKI.logger.info(`Sent ${template} email to <${to}>.`)
}
}
export const mail = new Mail()

@ -1321,6 +1321,35 @@ class Pages {
return true
}
/**
* Delete every page on a site carrying a tag.
*
* One page at a time through `deletePage` rather than one statement against the table: a page is
* more than its row a tree entry, a navigation menu keyed by its id, a copy on every storage
* target and a bulk `DELETE` would leave all of that behind. This is a handful of pages on a
* development instance, so the cost of doing it properly is nothing.
*
* The folders the pages sat in are left standing. They are not tagged, nothing is served from an
* empty one, and a folder somebody created themselves must not be swept up because a tagged page
* happened to be filed in it.
*
* @returns How many pages were deleted
*/
async deletePagesByTag(siteId: string, tag: string, actor: PageActor): Promise<number> {
const rows = await WIKI.db
.select({ id: pagesTable.id })
.from(pagesTable)
.where(and(eq(pagesTable.siteId, siteId), sql`${pagesTable.tags} @> ${sql.param([tag])}`))
let deleted = 0
for (const row of rows) {
if (await this.deletePage(siteId, row.id, actor)) {
deleted++
}
}
return deleted
}
/**
* Delete the pages left behind by a folder deletion, which removed their tree entries already.
*

@ -427,7 +427,8 @@ class Users {
password,
groups = [],
mustChangePassword = false,
isVerified = true
isVerified = true,
strategyId
}: {
name: string
email: string
@ -435,12 +436,22 @@ class Users {
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.
* Defaults to true: an administrator creating the account vouches for the address. Login rejects
* an unverified user with `ERR_USER_NOT_VERIFIED`, which is cleared either by the link in the
* registration email or by an administrator marking the account verified.
*/
isVerified?: boolean
/**
* Which local strategy the password is filed under. Defaults to the built-in one, which is where
* every account seeded or created by an administrator keeps it.
*
* It matters because the local module reads `user.auth[its own strategy ID]` a password stored
* under one local strategy authenticates nobody through another. A registration therefore files
* it under the strategy that was registered through, rather than assuming the built-in one.
*/
strategyId?: string
}): Promise<string> {
const localStrategyId = WIKI.data.systemIds.localAuthId
const localStrategyId = strategyId ?? WIKI.data.systemIds.localAuthId
const result = await WIKI.db
.insert(usersTable)
.values({
@ -1365,7 +1376,15 @@ class Users {
WIKI.logger.warn(errc)
throw new Error('ERR_TFA_FAILED')
}
} else if (str.config?.enforceTfa || authStr.tfaRequired) {
/*
`conf`, not `config`: what a module is constructed with is its stored settings, and every
one of them keeps them under that name. `config` is free for a module to use for something
else, and two of them do on the OIDC and Google strategies it holds the provider's
openid-client `Configuration`, which has no `enforceTfa` and never will. Read the wrong one
and this arm is simply never taken, which is what made "Enforce Two-Factor Authentication"
do nothing at all.
*/
} else if (str.conf?.enforceTfa || authStr.tfaRequired) {
try {
const { tfaQRImage } = await this.startTfaSetup(user, strategyId, context.siteId)
const tfaToken = await this.generateToken({
@ -1681,6 +1700,347 @@ class Users {
}
}
/**
* Create an account from the login screen's own registration form.
*
* Only the local module registers this way. The providers that sign users in elsewhere create
* accounts too, but they do it in `loginWithProvider()` on the way through a successful sign-in
* there is no form to fill in, and no password to choose.
*
* The strategy has to be one the site actually offers (`getSiteStrategy`), not merely one that
* exists: a strategy an administrator has taken off a site must stop creating accounts on it, and
* the ID is in the hands of anybody who has ever loaded that login screen.
*
* What happens next depends on the strategy's `emailValidation` prop. With it off the account is
* usable at once and this returns whatever an ordinary login would have including a 2FA setup,
* for a strategy that enforces one. With it on the account is created unverified, the address is
* sent a link, and `verifyEmail` is what comes back: there is nothing to log in to yet.
*
* @param baseUrl Where this wiki is reachable, for the link in the email
* @throws `ERR_INVALID_STRATEGY`, `ERR_REGISTRATION_DISABLED`, `ERR_EMAIL_NOT_ALLOWED`,
* `ERR_ACCOUNT_ALREADY_EXISTS`, `ERR_PASSWORD_TOO_SHORT`, `ERR_MAIL_NOT_CONFIGURED`
*/
async registerUser(
{
siteId,
strategyId,
name,
email,
password,
ip,
baseUrl
}: {
siteId: string
strategyId: string
name: string
email: string
password: string
ip?: string
baseUrl: string
},
req: any
): Promise<AfterLoginResult> {
const strategy = await WIKI.models.authentication.getSiteStrategy(siteId, strategyId)
if (!strategy || strategy.module !== 'local') {
WIKI.models.flags.authDebug(
`Registration on site ${siteId} refused: ${strategyId} is not a local strategy offered there`
)
throw new Error('ERR_INVALID_STRATEGY')
}
if (!strategy.registration) {
throw new Error('ERR_REGISTRATION_DISABLED')
}
if (!password || password.length < 8) {
throw new Error('ERR_PASSWORD_TOO_SHORT')
}
const address = email.toLowerCase().trim()
if (strategy.allowedEmailRegex) {
let allowed = false
try {
allowed = new RegExp(strategy.allowedEmailRegex).test(address)
} catch (err: any) {
// -> A pattern that will not compile allows nobody, rather than everybody
WIKI.logger.warn(
`Strategy ${strategy.id} has an invalid email pattern, refusing: ${err.message}`
)
}
if (!allowed) {
WIKI.models.flags.authDebug(
`Registration refused for <${address}>: the address is outside strategy ${strategy.id}'s allow-list`
)
throw new Error('ERR_EMAIL_NOT_ALLOWED')
}
}
if (await this.getByEmail(address)) {
throw new Error('ERR_ACCOUNT_ALREADY_EXISTS')
}
/*
An address this wiki has undertaken to check has to actually be checkable. Refusing here rather
than quietly creating a verified account is the point: the setting says addresses are confirmed,
and an instance with nothing to send the confirmation through cannot honour it. The alternative
-- an unverified account nobody can ever send a link to -- is a dead end that needs an
administrator either way, and this at least says so while somebody is looking.
*/
const mustVerify = strategy.config?.emailValidation === true
if (mustVerify && !WIKI.models.mail.isConfigured) {
WIKI.logger.warn(
`Registration refused: strategy ${strategy.id} validates email addresses, but no SMTP server is configured.`
)
throw new Error('ERR_MAIL_NOT_CONFIGURED')
}
const userId = await this.createUser({
name: name.trim(),
email: address,
password,
groups: strategy.autoEnrollGroups ?? [],
isVerified: !mustVerify,
strategyId: strategy.id
})
if (mustVerify) {
const token = await this.generateToken({
kind: 'verifyEmail',
userId,
meta: { strategyId: strategy.id, siteId }
})
try {
await WIKI.models.mail.send({
siteId,
to: address,
template: 'welcome',
data: {
name: name.trim(),
baseUrl,
// -> The login screen, which asks for a press before it confirms anything. Never an
// endpoint that would confirm on being fetched: the scanners that follow every link
// in a message before it is delivered would spend the token before the reader does.
verifyUrl: `${baseUrl}/login?verify=${token}`
}
})
} catch (err: any) {
/*
Undone rather than left behind. The account cannot be signed into and cannot be confirmed,
and leaving it would take the address with it -- a second attempt, after whatever was wrong
with the mail server is fixed, would be refused as already registered. Nothing else has
happened to it yet, so there is nothing else to unwind.
*/
await this.deleteUser(userId)
WIKI.logger.warn(`Could not send the verification email to <${address}>: ${err.message}`)
throw new Error('ERR_MAIL_SEND_FAILED')
}
WIKI.models.flags.authDebug(
`Registered user ${userId} <${address}> on site ${siteId} from ${ip}, pending email verification`
)
return {
nextAction: 'verifyEmail',
redirect: '/'
}
}
// -> Best effort, and deliberately not undone on failure: unlike the verification link this is a
// courtesy, and the account it welcomes works whether or not it arrives
if (WIKI.models.mail.isConfigured) {
try {
await WIKI.models.mail.send({
siteId,
to: address,
template: 'welcome',
data: { name: name.trim(), baseUrl }
})
} catch (err: any) {
WIKI.logger.warn(`Could not send the welcome email to <${address}>: ${err.message}`)
}
}
const user = await this.getById(userId)
WIKI.models.flags.authDebug(
`Registered user ${userId} <${address}> on site ${siteId} from ${ip}, signing them in`
)
return this.afterLoginChecks(user, strategy.id, { ip, siteId }, {}, req)
}
/**
* Tell somebody an account has been made for them.
*
* The administrator's version of what registration sends itself: no confirmation link, because an
* account an administrator created is verified by definition, and no password, because the mail is
* not the place for one. What it carries is the wiki's name and where to sign in.
*
* @param siteId Which site to welcome them to. Defaults to whichever one the request was addressed
* to, since an instance with one site has no choice to make.
* @throws `ERR_INVALID_USER`, `ERR_MAIL_NOT_CONFIGURED`, and whatever the mail server said
*/
async sendWelcomeEmail({
userId,
siteId,
req
}: {
userId: string
siteId?: string
req?: any
}): Promise<void> {
const user = await this.getById(userId)
if (!user) {
throw new Error('ERR_INVALID_USER')
}
const targetSiteId =
(siteId && WIKI.sites[siteId] ? siteId : null) ??
(await WIKI.models.sites.getSiteByHostname({ hostname: req?.hostname ?? '*' }))?.id ??
''
await WIKI.models.mail.send({
siteId: targetSiteId,
to: user.email,
template: 'welcome',
data: {
name: user.name,
baseUrl: WIKI.models.mail.baseUrl({ req, siteId: targetSiteId })
}
})
}
/**
* Confirm an address from the link in a registration email.
*
* The token is consumed whether or not it was still needed, so the link works once. Nobody is
* signed in by it: the browser reading the mail is not necessarily the one that registered, and
* whoever it is still has to know the password.
*
* @throws `ERR_INVALID_VALIDATION_TOKEN`, `ERR_EXPIRED_VALIDATION_TOKEN`, `ERR_INVALID_USER`
*/
async verifyUserEmail(token: string): Promise<void> {
const { user } = await this.validateToken({ kind: 'verifyEmail', token })
if (!user) {
throw new Error('ERR_INVALID_USER')
}
if (!user.isVerified) {
await WIKI.db
.update(usersTable)
.set({ isVerified: true, updatedAt: sql`now()` })
.where(eq(usersTable.id, user.id))
}
WIKI.models.flags.authDebug(`User ${user.id} <${user.email}> confirmed their email address`)
}
/**
* Send somebody the link they choose a new password from, if there is anybody to send it to.
*
* **This never says whether the address is registered.** It returns the same way for an account
* that got a mail, an address nobody here has, an account that signs in through a provider and has
* no local password, and a deactivated one because the form is public, and answering the question
* would make it a way to find out who has an account. What is not about the address is reported
* normally: a strategy that does not offer resets at all, and an instance with no mail server, are
* both misconfigurations rather than answers about a user.
*
* @param baseUrl Where this wiki is reachable, for the link in the email
* @throws `ERR_INVALID_STRATEGY`, `ERR_FORGOT_PASSWORD_DISABLED`, `ERR_MAIL_NOT_CONFIGURED`
*/
async requestPasswordReset({
siteId,
strategyId,
email,
ip,
baseUrl
}: {
siteId: string
strategyId: string
email: string
ip?: string
baseUrl: string
}): Promise<void> {
const strategy = await WIKI.models.authentication.getSiteStrategy(siteId, strategyId)
if (!strategy || strategy.module !== 'local') {
throw new Error('ERR_INVALID_STRATEGY')
}
if (strategy.config?.allowForgotPassword !== true) {
throw new Error('ERR_FORGOT_PASSWORD_DISABLED')
}
if (!WIKI.models.mail.isConfigured) {
WIKI.logger.warn(
'A password reset was requested, but no SMTP server is configured to send it through.'
)
throw new Error('ERR_MAIL_NOT_CONFIGURED')
}
const address = email.toLowerCase().trim()
const user = await this.getByEmail(address)
const auth = (user?.auth ?? {}) as Record<string, any>
if (!user || !user.isActive || !auth[strategy.id]?.password) {
WIKI.models.flags.authDebug(
`Password reset requested from ${ip} for <${address}>, which has no password on strategy ${strategy.id}; nothing sent`
)
return
}
const token = await this.generateToken({
kind: 'resetPwd',
userId: user.id,
meta: { strategyId: strategy.id, siteId }
})
await WIKI.models.mail.send({
siteId,
to: user.email,
template: 'resetPwd',
data: {
name: user.name,
baseUrl,
resetUrl: `${baseUrl}/login?reset=${token}`
}
})
WIKI.models.flags.authDebug(
`Password reset requested from ${ip} for user ${user.id} <${user.email}>, link sent`
)
}
/**
* Set the new password a reset link was followed to choose.
*
* The token is consumed on the first attempt, correct password or not unlike the 2FA
* continuation token, there is nothing to get wrong here that would need a second try, and a link
* sitting in a mailbox should stop working as soon as it has been used.
*
* The account is also marked verified. Following the link proves control of the mailbox, which is
* the whole of what verification asks and without this, somebody who registered, never got the
* confirmation and then reset their password would still not be able to sign in.
*
* Nobody is signed in by it: the new password is what does that, on the login screen.
*
* @throws `ERR_INVALID_VALIDATION_TOKEN`, `ERR_EXPIRED_VALIDATION_TOKEN`, `ERR_INVALID_USER`,
* `ERR_INVALID_STRATEGY`, `ERR_PASSWORD_TOO_SHORT`
*/
async resetPassword({
token,
newPassword
}: {
token: string
newPassword: string
}): Promise<void> {
if (!newPassword || newPassword.length < 8) {
throw new Error('ERR_PASSWORD_TOO_SHORT')
}
const { user, strategyId } = await this.validateToken({ kind: 'resetPwd', token })
if (!user) {
throw new Error('ERR_INVALID_USER')
}
const auth = (user.auth ?? {}) as Record<string, any>
// -> The strategy could have been deleted, or password login turned off, since the link was sent
if (!auth[strategyId]?.password) {
throw new Error('ERR_INVALID_STRATEGY')
}
auth[strategyId] = {
...auth[strategyId],
password: await bcrypt.hash(newPassword, 12),
mustChangePwd: false
}
await WIKI.db
.update(usersTable)
.set({ auth, isVerified: true, updatedAt: sql`now()` })
.where(eq(usersTable.id, user.id))
WIKI.models.flags.authDebug(`User ${user.id} <${user.email}> reset their password`)
}
updateSession(user: any, req: any): void {
req.session.authenticated = true
req.session.user = {

@ -18,7 +18,7 @@ props:
emailValidation:
type: Boolean
title: Email Validation
hint: Send a verification email to the user with a validation link when registering (if registration is enabled).
hint: Send a verification email with a validation link when somebody registers, and refuse them a login until they follow it. Requires a configured mail server — registration is refused outright without one.
icon: received
default: true
allowForgotPassword:

@ -46,6 +46,7 @@
"mime": "4.1.0",
"nanoid": "6.0.1",
"node-cache": "5.1.2",
"nodemailer": "7.0.13",
"openid-client": "6.8.4",
"pg": "8.23.0",
"poolifier": "5.3.2",
@ -62,6 +63,7 @@
"@types/fs-extra": "11.0.4",
"@types/js-yaml": "4.0.9",
"@types/node": "26.2.0",
"@types/nodemailer": "8.0.1",
"@types/pg": "8.21.0",
"@types/qrcode": "1.5.6",
"@types/sanitize-html": "2.16.1",
@ -3480,6 +3482,16 @@
"undici-types": "~8.3.0"
}
},
"node_modules/@types/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.21.0.tgz",
@ -6328,6 +6340,15 @@
}
}
},
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": {
"version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",

@ -72,6 +72,7 @@
"mime": "4.1.0",
"nanoid": "6.0.1",
"node-cache": "5.1.2",
"nodemailer": "7.0.13",
"openid-client": "6.8.4",
"pg": "8.23.0",
"poolifier": "5.3.2",
@ -91,6 +92,7 @@
"@types/fs-extra": "11.0.4",
"@types/js-yaml": "4.0.9",
"@types/node": "26.2.0",
"@types/nodemailer": "8.0.1",
"@types/pg": "8.21.0",
"@types/qrcode": "1.5.6",
"@types/sanitize-html": "2.16.1",

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#4788c7" d="M37.5,39h-23c-0.276,0-0.5-0.224-0.5-0.5l0,0c0-0.276,0.224-0.5,0.5-0.5h23 c0.276,0,0.5,0.224,0.5,0.5l0,0C38,38.776,37.776,39,37.5,39z"/><path fill="#dff0fe" d="M14.341,38.5c-0.637,0-1.236-0.248-1.687-0.698L2.198,27.346c-0.93-0.93-0.93-2.443,0-3.373 L23.973,2.199c0.45-0.451,1.05-0.699,1.687-0.699s1.236,0.248,1.687,0.699l10.456,10.456c0.914,0.914,0.914,2.458,0,3.373 L16.027,37.802C15.577,38.252,14.978,38.5,14.341,38.5z"/><path fill="#4788c7" d="M25.659,2c0.504,0,0.977,0.196,1.333,0.552l10.456,10.456c0.337,0.337,0.532,0.82,0.536,1.324 c0.004,0.516-0.187,0.992-0.536,1.342L15.674,37.448C15.318,37.804,14.844,38,14.341,38c-0.504,0-0.977-0.196-1.333-0.552 L2.552,26.992C2.196,26.636,2,26.163,2,25.659s0.196-0.977,0.552-1.333L24.326,2.552C24.682,2.196,25.156,2,25.659,2 M25.659,1 c-0.738,0-1.477,0.282-2.04,0.845L1.845,23.619c-1.127,1.127-1.127,2.953,0,4.08l10.456,10.456C12.864,38.718,13.603,39,14.341,39 c0.738,0,1.477-0.282,2.04-0.845l21.774-21.774c1.127-1.127,1.084-2.996,0-4.08L27.699,1.845C27.136,1.282,26.397,1,25.659,1 L25.659,1z"/><path fill="#98ccfd" d="M13.035,13.136L23.973,2.199c0.45-0.451,1.05-0.699,1.687-0.699s1.236,0.248,1.687,0.699 l10.456,10.456c0.779,0.78,0.949,2.423,0,3.373L26.864,26.964L13.035,13.136z"/><path fill="#4788c7" d="M25.659,2c0.504,0,0.977,0.196,1.333,0.552l10.456,10.456c0.293,0.293,0.487,0.784,0.507,1.281 c0.013,0.315-0.036,0.913-0.507,1.385L26.864,26.257L13.743,13.136L24.326,2.552C24.682,2.196,25.156,2,25.659,2 M25.659,1 c-0.738,0-1.477,0.282-2.04,0.845L12.329,13.136l14.536,14.536l11.291-11.291c1.189-1.189,0.943-3.137,0-4.08L27.699,1.845 C27.136,1.282,26.397,1,25.659,1L25.659,1z"/><path fill="#b6dcfe" d="M37.448,15.674l-1.237,1.237L23.089,3.789l1.237-1.237C24.682,2.196,25.156,2,25.659,2 s0.977,0.196,1.333,0.552l10.456,10.456C37.804,13.364,38,13.837,38,14.341C38,14.844,37.804,15.318,37.448,15.674z"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#b6dcfe" d="M8.309 22.562L6.601 20 8.36 17.36 11.036 15.577 14.5 17.309 14.5 22.64 11.98 23.479z"/><path fill="#4788c7" d="M11.072,16.154L14,17.618v4.661l-2.04,0.68l-3.342-0.836L7.202,20l1.519-2.279L11.072,16.154 M11,15 l-3,2l-2,3l2,3l4,1l3-1v-6L11,15L11,15z"/><path fill="#b6dcfe" d="M4.354 32.591L2.608 29.099 6.099 25.608 9.5 27.309 9.5 31.732 6.926 33.448z"/><path fill="#4788c7" d="M6.197,26.217L9,27.618v3.847l-2.148,1.432l-2.143-0.714l-1.492-2.985l1.49-1.49L6.197,26.217 M6,25 l-2,2l-2,2l2,4l3,1l3-2v-5L6,25L6,25z"/><path fill="#dff0fe" d="M24.5 1.5H31.5V5.5H24.5z"/><path fill="#4788c7" d="M31,2v3h-6V2H31 M32,1h-8v5h8V1L32,1z"/><path fill="#dff0fe" d="M17.5 35.5L17.5 17.833 11.732 10.144 21.139 4.5 35.5 4.5 35.5 35.5z"/><path fill="#4788c7" d="M35,5v30H18V18v-0.333L17.8,17.4l-5.335-7.113L21.277,5H35 M36,4H21l-10,6l6,8v18h19V4L36,4z"/><path fill="#98ccfd" d="M33 14L33 8 30 7 27 8 25 11 27 14z"/><g><path fill="#98ccfd" d="M24 15L24 10 22 8 19 9 17 12 19 15z"/></g><g><path fill="#98ccfd" d="M17.5 35.5L17.5 17.833 16.388 16.351 21.139 13.5 35.5 13.5 35.5 35.5z"/><path fill="#4788c7" d="M35,14v21H18V18v-0.333L17.8,17.4l-0.68-0.906L21.277,14H35 M36,13H21l-5.345,3.207L17,18v18h19V13 L36,13z"/></g><g><path fill="#fff" d="M26.5 25A3.5 3.5 0 1 0 26.5 32A3.5 3.5 0 1 0 26.5 25Z"/></g><g><path fill="#98ccfd" d="M16.5,38.5V37c0-0.827,0.673-1.5,1.5-1.5h17c0.827,0,1.5,0.673,1.5,1.5v1.5H16.5z"/><path fill="#4788c7" d="M35,36c0.551,0,1,0.449,1,1v1H17v-1c0-0.551,0.449-1,1-1H35 M35,35H18c-1.105,0-2,0.895-2,2v2h21 v-2C37,35.895,36.105,35,35,35L35,35z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@ -42,12 +42,26 @@ const SETS = ['mdi', 'la']
*/
const REF = /(["'`])([a-z0-9]+(?:-[a-z0-9]+)*:[a-z0-9]+(?:[-.][a-z0-9]+)*)\1/g
/**
* Files whose icon references are CONTENT rather than chrome, and so do not belong in this bundle.
*
* `sampleContent.js` is a set of pages the admin area writes into a wiki; the names in it end up in
* the `icon` column of a page, exactly as if an author had picked them, and resolve through `/_icons`
* like every other icon an author picks. Inlining them would put twenty-odd icons that only a
* development instance ever draws into the bundle every reader downloads.
*/
const NOT_CHROME = new Set(['sampleContent.js'])
function* sourceFiles(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
yield* sourceFiles(full)
} else if (/\.(vue|js)$/.test(entry.name) && !entry.name.endsWith('.generated.js')) {
} else if (
/\.(vue|js)$/.test(entry.name) &&
!entry.name.endsWith('.generated.js') &&
!NOT_CHROME.has(entry.name)
) {
yield full
}
}

@ -1,10 +1,25 @@
<template>
<div>
<!--
Nothing until the strategies are known.
Every screen below depends on them which fields the form has, what the username is called,
whether there is a register link or a forgot-password one, which providers get a button. Drawn
before the answer arrives, the panel is a guess that then corrects itself, and the correction
moved the form ~34px up the page a beat after it was first painted: this column is vertically
centred, so anything added below the form shifts the form. A field that jumps out from under
the pointer is a field that cannot be clicked, which is what made focusing the email address
take two tries.
-->
<div v-if="!state.strategiesLoaded" class="flex justify-center py-8">
<w-spinner color="primary" size="lg" />
</div>
<!-- ----------------------------------------------------- -->
<!-- LOGIN SCREEN -->
<!-- ----------------------------------------------------- -->
<template v-if="state.screen === `login`">
<template v-if="formStrategies.length > 1">
<template v-else-if="state.screen === `login`">
<p v-if="formStrategies.length < 2">{{ t('auth.enterCredentials') }}</p>
<template v-else>
<p>{{ t('auth.selectAuthProvider') }}</p>
<div class="auth-strategies mb-4">
<w-btn
@ -27,6 +42,16 @@
</div>
</template>
<w-form ref="loginForm" @submit="login">
<!--
`username`, whatever the strategy calls this field. It is the account identifier of a login
form, which is what that token means an address typed here is not an email address being
collected, it is the name of an account, and `email` is the token for the former. What reads
the difference is the browser's password manager and every extension standing in for one:
`username` beside `current-password` is the pair they look for, and a form they classify
some other way gets treated some other way.
The LABEL still follows the strategy, since that is what the reader is being asked for.
-->
<w-input
ref="loginEmailIpt"
v-model="state.username"
@ -42,7 +67,7 @@
"
lazy-rules="ondemand"
hide-bottom-space
:autocomplete="selectedStrategy.activeStrategy?.strategy?.usernameType ?? `email`">
autocomplete="username">
<template #prepend><w-icon name="la:user" /></template>
</w-input>
<w-input
@ -162,6 +187,90 @@
@click="switchTo(`login`)" />
</template>
<!-- ----------------------------------------------------- -->
<!-- CONFIRM EMAIL SCREEN -->
<!-- ----------------------------------------------------- -->
<!--
A button rather than something this screen does on arrival. The link that leads here is fetched
by the scanners some mail providers put in front of a mailbox Outlook's Safe Links among them
and anything that confirmed the address on load would be spent by the scanner, leaving the
reader with a link that has already been used.
-->
<template v-else-if="state.screen === `verifyEmail`">
<p>{{ t('auth.verifyEmail.instructions') }}</p>
<w-btn
class="w-full mt-2"
push
color="primary"
:label="t(`auth.verifyEmail.proceed`)"
no-caps
icon="la:check-circle"
@click="confirmEmail" />
<w-separator class="my-4" />
<w-btn
class="acrylic-btn w-full"
flat
color="primary"
:label="t(`auth.switchToLogin.link`)"
no-caps
icon="la:arrow-circle-left"
@click="cancelVerifyEmail" />
</template>
<!-- ----------------------------------------------------- -->
<!-- RESET PASSWORD SCREEN -->
<!-- ----------------------------------------------------- -->
<template v-else-if="state.screen === `resetPwd`">
<p>{{ t('auth.resetPwd.instructions') }}</p>
<w-form ref="resetPwdForm" @submit="resetPassword">
<w-input
v-model="state.newPassword"
autofocus
outlined
:label="t(`auth.changePwd.newPassword`)"
type="password"
autocomplete="new-password"
:rules="userPasswordValidation"
hide-bottom-space
lazy-rules="ondemand">
<template #append>
<w-badge
v-show="state.newPassword"
:color="passwordStrength.color"
:label="passwordStrength.label" />
</template>
<template #prepend><w-icon name="la:key" /></template>
</w-input>
<w-input
class="mt-2"
v-model="state.newPasswordVerify"
outlined
:label="t(`auth.changePwd.newPasswordVerify`)"
type="password"
autocomplete="new-password"
:rules="userPasswordVerifyValidation"
hide-bottom-space
lazy-rules="ondemand">
<template #prepend><w-icon name="la:key" /></template>
</w-input>
<w-btn
class="w-full mt-2"
type="submit"
push
color="primary"
:label="t(`auth.changePwd.proceed`)"
no-caps
icon="la:sync-alt" />
</w-form>
<w-separator class="my-4" />
<w-btn
class="acrylic-btn w-full"
flat
color="primary"
:label="t(`auth.switchToLogin.link`)"
no-caps
icon="la:arrow-circle-left"
@click="cancelReset" />
</template>
<!-- ----------------------------------------------------- -->
<!-- REGISTER SCREEN -->
<!-- ----------------------------------------------------- -->
<template v-else-if="state.screen === `register`">
@ -395,6 +504,12 @@ const state = reactive({
newEmail: '',
newPassword: '',
newPasswordVerify: '',
/** Set from `?reset=` on the way in; what the reset screen submits with the new password. */
resetToken: '',
/** Set from `?verify=` on the way in; what the confirm screen submits when it is pressed. */
verifyToken: '',
/** Whether the strategies have been fetched — settled either way, so a failure still draws. */
strategiesLoaded: false,
isTFAShown: false,
isTFASetupShown: false,
tfaQRImage: ''
@ -411,6 +526,7 @@ const loginForm = ref(null)
const forgotForm = ref(null)
const registerForm = ref(null)
const changePwdForm = ref(null)
const resetPwdForm = ref(null)
// COMPUTED
@ -534,13 +650,27 @@ function switchTo(screen) {
}
async function fetchStrategies(showAll = false) {
state.strategies = await API_CLIENT.get(`sites/${siteStore.id}/auth/strategies`, {
searchParams: {
visibleOnly: !showAll
}
}).json()
// -> The selection drives the form, so it has to be a strategy that has one
state.selectedStrategyId = formStrategies.value[0]?.id ?? null
try {
state.strategies = await API_CLIENT.get(`sites/${siteStore.id}/auth/strategies`, {
searchParams: {
visibleOnly: !showAll
}
}).json()
// -> The selection drives the form, so it has to be a strategy that has one
state.selectedStrategyId = formStrategies.value[0]?.id ?? null
} catch (err) {
// -> Said out loud rather than left as an unhandled rejection: what the reader is looking at is
// a login form with no strategy behind it, and a submit that cannot go anywhere
notify({
type: 'negative',
message: t('auth.genericError'),
caption: apiErrorMessage(err)
})
} finally {
// -> In a `finally`, so a wiki whose strategies could not be fetched still shows its form rather
// than spinning for ever
state.strategiesLoaded = true
}
}
/**
@ -591,6 +721,20 @@ async function handleLoginResponse(resp) {
loading.hide()
break
}
/*
Registration on a site that confirms addresses. Nothing to continue here: the link in the email
is what finishes it, and there is no session until the new account signs in for itself.
*/
case 'verifyEmail': {
loading.hide()
notify({
type: 'positive',
message: t('auth.registerSuccess'),
caption: t('auth.registerCheckEmail')
})
switchTo('login')
break
}
case 'redirect': {
loading.show({
message: t('auth.loginSuccess')
@ -701,20 +845,85 @@ async function loginWithPasskey() {
* FORGOT PASSWORD
*/
async function forgotPassword() {
loading.show({
message: t('auth.forgotPasswordLoading')
})
try {
const isFormValid = await forgotForm.value.validate(true)
if (!isFormValid) {
throw new Error(t('auth.errors.forgotPassword'))
}
// TODO: Implement forgot password
const resp = await API_CLIENT.post(`sites/${siteStore.id}/auth/forgotPassword`, {
json: {
strategyId: state.selectedStrategyId,
email: state.username
},
throwHttpErrors: (statusNumber) => statusNumber > 400 // Don't throw for 400
}).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'ERR_FORGOT_PASSWORD_FAILED')
}
loading.hide()
/*
The same answer for an address nobody here has, which is what the endpoint gives back and what
this has to keep: saying "no such account" on a public form is how a wiki's member list gets
read off it one address at a time.
*/
notify({
type: 'positive',
message: t('auth.forgotPasswordSuccess')
})
switchTo('login')
} catch (err) {
loading.hide()
notify({
type: 'negative',
message: 'Not implemented yet.'
message: localizeError(apiErrorMessage(err), t)
})
}
}
/**
* RESET PASSWORD
*
* The other end of the link in a reset email. Nothing signs the user in here the token stands for
* the mailbox rather than for a half-finished login so what follows a successful reset is the login
* screen, with the new password to type into it.
*/
async function resetPassword() {
loading.show({
message: t('auth.changePwd.loading')
})
try {
const isFormValid = await resetPwdForm.value.validate(true)
if (!isFormValid) {
throw new Error(t('auth.errors.fields'))
}
const resp = await API_CLIENT.post(`sites/${siteStore.id}/auth/resetPassword`, {
json: {
token: state.resetToken,
newPassword: state.newPassword
},
throwHttpErrors: (statusNumber) => statusNumber > 400 // Don't throw for 400
}).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'ERR_CHANGE_PASSWORD_FAILED')
}
loading.hide()
state.resetToken = ''
state.newPassword = ''
state.newPasswordVerify = ''
clearQueryParams(['reset'])
notify({
type: 'positive',
message: t('auth.resetPwd.success')
})
switchTo('login')
} catch (err) {
loading.hide()
notify({
type: 'negative',
message: err.message
message: localizeError(apiErrorMessage(err), t)
})
}
}
@ -728,48 +937,32 @@ async function register() {
if (!isFormValid) {
throw new Error(t('auth.errors.register'))
}
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation(
$email: String!
$password: String!
$name: String!
) {
register(
email: $email
password: $password
name: $name
) {
operation {
succeeded
message
}
jwt
nextAction
continuationToken
redirect
tfaQRImage
}
}
`,
variables: {
email: state.newEmail,
password: state.newPassword,
name: state.newName
}
loading.show({
message: t('auth.registering')
})
if (resp.data?.register?.operation?.succeeded) {
state.password = ''
state.newPassword = ''
state.newPasswordVerify = ''
await handleLoginResponse(resp.data.register)
} else {
throw new Error(resp.data?.register?.operation?.message || t('auth.errors.registerError'))
const resp = await API_CLIENT.post(`sites/${siteStore.id}/auth/register`, {
json: {
strategyId: state.selectedStrategyId,
name: state.newName,
email: state.newEmail,
password: state.newPassword
},
throwHttpErrors: (statusNumber) => statusNumber > 400 // Don't throw for 400
}).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'ERR_REGISTRATION_FAILED')
}
state.password = ''
state.newName = ''
state.newEmail = ''
state.newPassword = ''
state.newPasswordVerify = ''
await handleLoginResponse(resp)
} catch (err) {
loading.hide()
notify({
type: 'negative',
message: err.message
message: localizeError(apiErrorMessage(err), t)
})
}
}
@ -888,9 +1081,74 @@ async function finishSetupTFA() {
}
}
/**
* CONFIRM EMAIL
*
* The press the confirm screen exists for. Nobody is signed in by it the browser reading the mail
* is not necessarily the one that registered so what follows is the login screen.
*/
async function confirmEmail() {
loading.show({
message: t('auth.verifyEmail.loading')
})
try {
const resp = await API_CLIENT.post(`sites/${siteStore.id}/auth/verifyEmail`, {
json: {
token: state.verifyToken
},
throwHttpErrors: (statusNumber) => statusNumber > 400 // Don't throw for 400
}).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'ERR_INVALID_VALIDATION_TOKEN')
}
loading.hide()
notify({
type: 'positive',
message: t('auth.verifyEmail.success')
})
cancelVerifyEmail()
} catch (err) {
loading.hide()
notify({
type: 'negative',
message: localizeError(apiErrorMessage(err), t)
})
// -> Nothing left to press: a token that was refused is not going to be accepted on a second try
cancelVerifyEmail()
}
}
/**
* Leave the confirm screen, spent token or backed-out one alike, and take it out of the address bar:
* left there, reloading the page would offer a confirmation that cannot succeed.
*/
function cancelVerifyEmail() {
state.verifyToken = ''
clearQueryParams(['verify'])
switchTo('login')
}
/**
* Leave the reset screen without using the link, and take the token out of the address bar with it:
* left there, reloading the page would drop the reader straight back into a screen they backed out of.
*/
function cancelReset() {
state.resetToken = ''
state.newPassword = ''
state.newPasswordVerify = ''
clearQueryParams(['reset'])
switchTo('login')
}
// MOUNTED
onMounted(async () => {
/*
Before the fetch, and therefore before anything is drawn: the panel is held back until the
strategies arrive, so setting the screen now means the right one is the FIRST to mount. Done
afterwards it would mount the login form, focus its email field and then take both away again.
*/
screenFromQuery()
await fetchStrategies()
reportRedirectLoginError()
})
@ -903,8 +1161,7 @@ onMounted(async () => {
* address bar afterwards, so that reloading the page does not report it a second time.
*/
function reportRedirectLoginError() {
const params = new URLSearchParams(window.location.search)
const code = params.get('error')
const code = new URLSearchParams(window.location.search).get('error')
if (!code) {
return
}
@ -913,7 +1170,43 @@ function reportRedirectLoginError() {
message: t('auth.errors.loginError'),
caption: localizeError(code, t)
})
params.delete('error')
clearQueryParams(['error'])
}
/**
* Which screen a link asked for, and the token it carries.
*
* Both are one-way: nothing switches to them afterwards, so unlike `switchTo` this only sets state
* the screen's own field focuses itself when it mounts. The token stays in the address bar until it
* has been used or the screen is left, since it is what the request is made with and a reload before
* then should land back here rather than on a form with nothing behind it.
*/
function screenFromQuery() {
const params = new URLSearchParams(window.location.search)
const verify = params.get('verify')
if (verify) {
state.verifyToken = verify
state.screen = 'verifyEmail'
return
}
const reset = params.get('reset')
if (reset) {
state.resetToken = reset
state.screen = 'resetPwd'
}
}
/**
* Take parameters out of the address bar without navigating.
*
* Everything this screen is told by URL a failed provider login, a confirmation token, a reset
* token is spent once it has been acted on, and reloading the page must not offer it again.
*/
function clearQueryParams(keys) {
const params = new URLSearchParams(window.location.search)
for (const key of keys) {
params.delete(key)
}
const query = params.toString()
window.history.replaceState(
window.history.state,

@ -36,8 +36,7 @@
:rules="newPasswordValidation"
hide-bottom-space
:label="t(`auth.changePwd.newPassword`)"
lazy-rules="ondemand"
autofocus>
lazy-rules="ondemand">
<template #append>
<div class="flex flex-nowrap items-center">
<w-badge :color="passwordStrength.color" :label="passwordStrength.label" />
@ -63,8 +62,7 @@
:rules="verifyPasswordValidation"
hide-bottom-space
:label="t(`auth.changePwd.newPasswordVerify`)"
lazy-rules="ondemand"
autofocus />
lazy-rules="ondemand" />
</w-item-section>
</w-item>
</w-form>

@ -126,11 +126,12 @@
<w-item v-if="state.userSendWelcomeEmail">
<blueprint-icon icon="web-design" />
<w-item-section>
<!-- -> One site, not several: the mail carries that site's name and points at it, and
the endpoint takes a single ID -->
<w-select
v-model="state.userSendWelcomeEmailFromSiteId"
outlined
:options="adminStore.sites"
multiple
map-options
emit-value
option-value="id"
@ -340,6 +341,15 @@ async function create() {
type: 'positive',
message: t('admin.users.createSuccess')
})
// -> The account exists either way, so this is reported beside the success rather than instead of
// it. The user's Operations tab is where it can be tried again.
if (resp.welcomeEmailError) {
notify({
type: 'negative',
message: t('admin.users.sendWelcomeEmailFailed'),
caption: resp.welcomeEmailError
})
}
if (state.keepOpened) {
state.userName = ''
state.userEmail = ''

@ -571,6 +571,7 @@
color="primary"
v-if="canManage"
@click="sendWelcomeEmail"
:loading="state.welcomeEmailLoading"
:label="t(`common.actions.proceed`)" />
</w-item-section>
</w-item>
@ -710,6 +711,7 @@ const state = reactive({
groups: [],
groupToAdd: null,
loading: 0,
welcomeEmailLoading: false,
metadataInvalidJSON: false
})
@ -926,7 +928,33 @@ function invalidateTFA() {
})
}
async function sendWelcomeEmail() {}
async function sendWelcomeEmail() {
// -> Not `state.loading`, which the whole overlay hides itself behind: this is one row's button
state.welcomeEmailLoading = true
try {
// -> The site the admin area is being used on decides what the mail calls this wiki and where it
// points; the endpoint takes that from the request when nothing is named
const resp = await API_CLIENT.post(`users/${adminStore.overlayOpts.id}/send-welcome-email`, {
json: {
siteId: adminStore.currentSiteId
}
}).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
notify({
type: 'positive',
message: t('admin.users.sendWelcomeEmailSuccess')
})
} catch (err) {
notify({
type: 'negative',
message: t('admin.users.sendWelcomeEmailFailed'),
caption: apiErrorMessage(err)
})
}
state.welcomeEmailLoading = false
}
function toggleVerified() {
state.user.isVerified = !state.user.isVerified

@ -154,7 +154,7 @@
</template>
<script setup>
import { computed, inject, ref, useId, useSlots, watch } from 'vue'
import { computed, inject, onMounted, ref, useId, useSlots, watch } from 'vue'
/**
* Text input.
@ -244,6 +244,22 @@ const props = defineProps({
type: String,
default: null
},
/**
* Put the caret in this field as soon as it is on screen.
*
* A prop rather than the native attribute, which is what the markup used to carry and what did
* nothing: an attribute this component does not declare falls through to the root element, and
* `autofocus` on a plain `<div>` focuses nothing. Only one field per screen may have it several
* would simply race, and the last one mounted would win.
*
* Not for a control that arrives with the screen already open. `focus()` is exposed for that,
* because focus is an action taken at a moment rather than a state of the field; see
* `composables/dialog.js`, which focuses on open, and `UtilCodeEditor` on the same reasoning.
*/
autofocus: {
type: Boolean,
default: false
},
/** Rows for `type="textarea"`. */
rows: {
type: [String, Number],
@ -455,6 +471,14 @@ const outlineStyle = computed(() => ({
borderWidth: `${frameWidth.value}px`
}))
// LIFECYCLE
onMounted(() => {
if (props.autofocus) {
inputEl.value?.focus()
}
})
// METHODS
/**

@ -0,0 +1,996 @@
/**
* The pages the admin area's **Generate Sample Content** writes, and the tag it puts on them.
*
* Content rather than code: a development instance starts empty, and checking a stylesheet, a
* renderer or the navigation against it means writing dummy pages by hand first. This is that
* writing, done once.
*
* **Loaded on demand.** `AdminUtilities.vue` imports this dynamically, so the whole set sits in a
* chunk nobody fetches unless they press the button.
*
* Generated here rather than on the server for one reason: a page stores the HTML its editor produced,
* and the only markdown renderer this project has is the one in `renderers/markdown.js`, which runs in
* a browser. Rendering server-side means driving a headless browser through the Puppeteer extension,
* which a plain checkout does not install so a backend generator would write pages that are blank
* until somebody re-renders them, which is precisely the opposite of the point.
*
* Paths are absolute from the site root, and the folders in them are created by the tree as each page
* lands. Links between the pages are written the same way, which is what makes the set navigable.
*/
/**
* The tag every page here carries, and the only thing the purge looks for.
*
* **Also written in `backend/api/system.ts`** as `SAMPLE_CONTENT_TAG`, since the three workspaces
* share no package. Changing one without the other leaves content nothing will clean up.
*/
export const SAMPLE_CONTENT_TAG = 'test'
/**
* @typedef {object} SamplePage
* @property {string} path Absolute from the site root, without a leading slash.
* @property {string} title
* @property {string} description
* @property {string} icon An Iconify reference, materialized before the pages are written.
* @property {string[]} tags Beside {@link SAMPLE_CONTENT_TAG}, which is added to every page.
* @property {string} content Markdown source. The render is produced from it at generation time.
*/
/** @type {SamplePage[]} */
export const SAMPLE_PAGES = [
{
path: 'sample/home',
title: 'Sample Content',
description: 'A tour of everything a page can do in this wiki.',
icon: 'mdi:book-open-variant',
tags: ['guide'],
content: `# Sample Content
Every page under here was written by **Generate Sample Content** in the admin area's Utilities
page. It exists so a fresh instance has something to look at formatting to check a stylesheet
against, blocks to check a renderer against, and a folder tree deep enough to exercise navigation.
> [!NOTE] Everything here is disposable
> Every one of these pages carries the \`test\` tag. **Purge Sample Content**, on the same Utilities
> page, deletes exactly those and nothing else.
## Formatting
How the markdown renderer draws the ordinary things.
- [Text Formatting](/sample/formatting/text) headings, emphasis, and the inline marks
- [Lists and Tasks](/sample/formatting/lists) bullets, numbers, definitions, checkboxes
- [Tables](/sample/formatting/tables) alignment, spans of content, a wide one that scrolls
- [Code Blocks](/sample/formatting/code) titles, line numbering, highlighted lines
- [Alerts and Quotes](/sample/formatting/alerts) the five GitHub alert kinds
- [Links, Images and Footnotes](/sample/formatting/media) how a page points elsewhere
## Blocks
The web components a page can embed. Each is a \`::block-name\` in the source.
- [Tabs](/sample/blocks/tabs)
- [Diagrams](/sample/blocks/diagrams)
- [Mathematics](/sample/blocks/math)
- [Infoboxes and Spoilers](/sample/blocks/callouts)
- [Widgets](/sample/blocks/widgets)
- [Index and Include](/sample/blocks/navigation)
## A folder tree to walk
- [Getting Started](/sample/guides/getting-started/installation) three pages, two levels down
- [Advanced](/sample/guides/advanced/permissions) three more beside them
- [Reference](/sample/reference/glossary) a glossary, an API page and a changelog
## What is under here
::block-index{path="sample" depth="2" columns="2" showIcons="true"}
::
`
},
{
path: 'sample/formatting/text',
title: 'Text Formatting',
description: 'Headings, emphasis, and every inline mark the renderer understands.',
icon: 'mdi:format-text',
tags: ['formatting'],
content: `# Text Formatting
The first heading on a page is its title in the table of contents; everything below nests under it.
## Second level
### Third level
#### Fourth level
Regular text, with *emphasis*, **strong emphasis**, ***both at once***, ~~struck through~~ and
\`inline code\`. The renderer also draws ==highlighted text==, H~2~O as a subscript and E=mc^2^ as a
superscript.
Typography is applied where it is turned on: "quotes" become curly ones, -- becomes an en dash,
--- an em dash, and ... an ellipsis.
## Abbreviations
The HTML spec is what a browser implements, and CSS is what it paints with.
*[HTML]: HyperText Markup Language
*[CSS]: Cascading Style Sheets
## A horizontal rule
---
## Line breaks
A paragraph is separated by a blank line.
This line follows a single newline, which is a break only where the editor has soft breaks on.
## See also
- [Lists and Tasks](/sample/formatting/lists)
- [Alerts and Quotes](/sample/formatting/alerts)
- [Back to the sample home](/sample/home)
`
},
{
path: 'sample/formatting/lists',
title: 'Lists and Tasks',
description: 'Bulleted, numbered, nested, definition and task lists.',
icon: 'mdi:format-list-bulleted',
tags: ['formatting'],
content: `# Lists and Tasks
## Bulleted
- A first item
- A second item
- Nested one level
- And another
- Two levels down
- Back to the top level
## Numbered
1. Install the wiki
2. Configure a storage target
3. Write a page
1. Give it a title
2. Give it some content
4. Publish it
## Tasks
- [x] Write the sample content generator
- [x] Tag every page it writes
- [ ] Decide what to have for lunch
- [ ] Purge it all again
## Definitions
Page
: A document in the wiki, addressed by its path.
Folder
: A branch of the tree. It holds pages and other folders, and is not a page itself.
Block
: A web component embedded in a page's source with \`::block-name\`.
## See also
- [Text Formatting](/sample/formatting/text)
- [Tables](/sample/formatting/tables)
- [Back to the sample home](/sample/home)
`
},
{
path: 'sample/formatting/tables',
title: 'Tables',
description:
'Column alignment, inline formatting inside cells, and a table wide enough to scroll.',
icon: 'mdi:table',
tags: ['formatting'],
content: `# Tables
## Alignment
| Left | Centered | Right |
| :---------- | :----------: | ------------: |
| \`markdown\` | Default | 42 |
| \`html\` | WYSIWYG | 1,024 |
| \`asciidoc\` | Optional | 7 |
## Formatting inside cells
| Setting | Default | What it does |
| -------------------- | --------- | --------------------------------------------------- |
| **\`sitePrefix\`** | \`false\` | Files the tree under a folder named after the site |
| **\`localePrefix\`** | \`true\` | Brackets the tree by locale |
| **\`largeThreshold\`** | \`10 MB\` | The size at which a file becomes *large* |
## A wide one
A table wider than the page scrolls inside its own box rather than stretching it.
| Target | Reads | Writes | Presigns | History | Notes |
| ------ | :---: | :----: | :------: | :-----: | ----------------------------------------- |
| \`db\` | yes | yes | no | no | Always on, cannot be turned off |
| \`disk\` | yes | yes | no | no | The wiki's tree as files on a filesystem |
| \`git\` | yes | yes | no | yes | The same tree, committed and synced |
| \`s3\` | yes | yes | yes | no | S3 and anything speaking its API |
| \`azure\`| yes | yes | yes | no | Azure Blob Storage |
| \`gcs\` | yes | yes | yes | no | Google Cloud Storage |
| \`sftp\` | yes | yes | no | no | A copy, never a delivery source |
## See also
- [Code Blocks](/sample/formatting/code)
- [Storage Targets](/sample/guides/advanced/storage)
- [Back to the sample home](/sample/home)
`
},
{
path: 'sample/formatting/code',
title: 'Code Blocks',
description: 'Fenced code with a title, a starting line number and highlighted lines.',
icon: 'mdi:code-braces',
tags: ['formatting'],
content: `# Code Blocks
A plain fence, with the language named:
\`\`\`js
const wiki = await connect()
await wiki.pages.create({ path: 'home', title: 'Home' })
\`\`\`
## With a title
\`\`\`ts title="backend/models/mail.ts"
export const mail = new Mail()
\`\`\`
## Numbered from somewhere else
Useful when the excerpt starts partway through a file.
\`\`\`ts title="api/authentication.ts" linesStart=482
app.post('/sites/:siteId/auth/verifyEmail', {
config: { publicAccess: true },
onRequest: limitAuthAttempts
}, async (req, reply) => {
await WIKI.models.users.verifyUserEmail(req.body.token)
return { ok: true }
})
\`\`\`
## With lines called out
\`\`\`js title="fetchStrategies" linesHighlight="3,6-8"
async function fetchStrategies() {
try {
state.strategies = await API_CLIENT.get('auth/strategies').json()
} catch (err) {
notify({ type: 'negative', message: err.message })
} finally {
state.strategiesLoaded = true
}
}
\`\`\`
## Other languages
\`\`\`yaml title="config.yml"
port: 3000
db:
host: db
user: postgres
\`\`\`
\`\`\`sql
SELECT "folderPath", "fileName" FROM tree WHERE tree = 'folder';
\`\`\`
\`\`\`bash
node backend
\`\`\`
## See also
- [Tables](/sample/formatting/tables)
- [Diagrams](/sample/blocks/diagrams)
- [Back to the sample home](/sample/home)
`
},
{
path: 'sample/formatting/alerts',
title: 'Alerts and Quotes',
description: 'The five GitHub alert kinds, and ordinary block quotes.',
icon: 'mdi:alert-circle-outline',
tags: ['formatting'],
content: `# Alerts and Quotes
## The five kinds
> [!NOTE]
> Useful information a reader should take in even when skimming.
> [!TIP] Give it a title of your own
> Text after the marker replaces the label, which is how an aside says what it is about rather than
> only what kind of thing it is.
> [!IMPORTANT]
> Something the reader needs in order to succeed at what they came here to do.
> [!WARNING]
> Something that deserves immediate attention to avoid a problem.
> [!CAUTION]
> A risk of something going irreversibly wrong.
## Ordinary quotes
> A block quote is not an alert. It is somebody else's words.
>
> Someone, probably
Quotes nest:
> The outer quote.
>
> > And one inside it.
## See also
- [Text Formatting](/sample/formatting/text)
- [Infoboxes and Spoilers](/sample/blocks/callouts)
- [Back to the sample home](/sample/home)
`
},
{
path: 'sample/formatting/media',
title: 'Links, Images and Footnotes',
description: 'How a page points at another page, at a file, and at a note of its own.',
icon: 'mdi:link-variant',
tags: ['formatting'],
content: `# Links, Images and Footnotes
## Links within the wiki
An absolute path addresses a page from the site root: [the glossary](/sample/reference/glossary),
[the permissions guide](/sample/guides/advanced/permissions), or
[three folders down](/sample/guides/getting-started/first-page).
A link can also carry a fragment, to land on a heading: [straight to the tables](/sample/formatting/tables#a-wide-one).
## Links that leave
[The Wiki.js website](https://js.wiki) is marked as external by the renderer, because it resolves to
a different origin than the page it is written on.
## Images
An image is addressed the way a file beside the page would be, and resolved at render time so the
source stays readable if the page is ever exported to a repository.
![The wiki's own logo](/_assets/logo-wikijs.svg =120x)
## Footnotes
The storage system writes to every target that claims a content type[^write] and reads from exactly
one[^read].
[^write]: An upload goes to all of them; a write that fails anywhere fails the upload.
[^read]: \`assetDelivery.servedTypes\` names it, at most one target per type.
## See also
- [Index and Include](/sample/blocks/navigation)
- [Back to the sample home](/sample/home)
`
},
{
path: 'sample/blocks/tabs',
title: 'Tabs',
description: 'Content split across tabbed panels.',
icon: 'mdi:tab',
tags: ['blocks'],
content: `# Tabs
A set of tabs is fenced with three colons, because the panels inside it are blocks of their own.
:::block-tabs
::block-tab{label="npm"}
Install the dependencies from the workspace directory:
\`\`\`bash
npm install
\`\`\`
::
::block-tab{label="Docker"}
Or build the production image:
\`\`\`bash
docker build -f dev/build/Dockerfile -t wikijs .
\`\`\`
::
::block-tab{label="From source"}
Node 26 runs the backend's TypeScript directly, so there is no build step:
\`\`\`bash
node backend
\`\`\`
::
:::
## Tabs with icons
:::block-tabs
::block-tab{label="Linux" icon="mdi:linux"}
Everything the wiki needs is in the package manager.
::
::block-tab{label="macOS" icon="mdi:apple"}
Homebrew has Node and PostgreSQL.
::
::block-tab{label="Windows" icon="mdi:microsoft-windows"}
Use the installers, or WSL.
::
:::
## See also
- [Diagrams](/sample/blocks/diagrams)
- [Back to the sample home](/sample/home)
`
},
{
path: 'sample/blocks/diagrams',
title: 'Diagrams',
description: 'Mermaid and Kroki, rendered in the page.',
icon: 'mdi:sitemap-outline',
tags: ['blocks'],
content: `# Diagrams
## Mermaid
::block-diagram{caption="What happens when somebody registers" align="center"}
\`\`\`mermaid
flowchart TD
A[Register form] --> B{Email validation on?}
B -->|No| C[Signed in straight away]
B -->|Yes| D[Account created unverified]
D --> E[Confirmation email sent]
E --> F[Reader presses Confirm]
F --> G[Account verified]
G --> H[Sign in]
\`\`\`
::
## A sequence
::block-diagram{caption="A password reset, end to end"}
\`\`\`mermaid
sequenceDiagram
participant R as Reader
participant W as Wiki
participant M as Mail server
R->>W: I forgot my password
W->>M: Send a reset link
W-->>R: Check your email
M-->>R: Reset link
R->>W: Here is my new password
W-->>R: Done, sign in
\`\`\`
::
## Kroki
::block-kroki{type="graphviz" caption="A tiny graph"}
\`\`\`kroki
digraph G {
rankdir=LR
Pages -> Tree
Pages -> Storage
Storage -> Disk
Storage -> Git
}
\`\`\`
::
## See also
- [Mathematics](/sample/blocks/math)
- [Code Blocks](/sample/formatting/code)
- [Back to the sample home](/sample/home)
`
},
{
path: 'sample/blocks/math',
title: 'Mathematics',
description: 'Formulas rendered with KaTeX and MathJax.',
icon: 'mdi:function-variant',
tags: ['blocks'],
content: `# Mathematics
## KaTeX
::block-katex{caption="The quadratic formula"}
\`\`\`latex
x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}
\`\`\`
::
::block-katex{align="left"}
\`\`\`latex
\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}
\`\`\`
::
## MathJax
::block-mathjax{caption="Euler's identity"}
\`\`\`latex
e^{i\\pi} + 1 = 0
\`\`\`
::
## See also
- [Diagrams](/sample/blocks/diagrams)
- [Back to the sample home](/sample/home)
`
},
{
path: 'sample/blocks/callouts',
title: 'Infoboxes and Spoilers',
description: 'A summary box beside the text, and content hidden until it is asked for.',
icon: 'mdi:card-text-outline',
tags: ['blocks'],
content: `# Infoboxes and Spoilers
## An infobox
::block-infobox{name="Wiki.js" image="/_assets/logo-wikijs.svg" imageCaption="The project logo"}
\`\`\`yaml
Written in: JavaScript and TypeScript
License: AGPL-3.0
Database: PostgreSQL 16+
Runtime: Node.js 26+
Website: https://js.wiki
\`\`\`
::
The box floats beside the text on a wide screen and stacks above it on a narrow one, so a paragraph
of ordinary content is needed to see the difference. This is that paragraph, and it goes on a little
longer than it strictly needs to for exactly that reason.
## A spoiler
::block-spoiler{label="The answer" hint="Click to reveal"}
Forty-two. The content is laid out either way and only hidden from view, so nothing below the box
moves when it opens.
::
## See also
- [Alerts and Quotes](/sample/formatting/alerts)
- [Widgets](/sample/blocks/widgets)
- [Back to the sample home](/sample/home)
`
},
{
path: 'sample/blocks/widgets',
title: 'Widgets',
description: 'A QR code and a countdown.',
icon: 'mdi:widgets-outline',
tags: ['blocks'],
content: `# Widgets
## QR code
::block-qr-code{value="https://js.wiki" size="180" caption="js.wiki"}
::
## Countdown
::block-countdown{date="2030-01-01T00:00:00Z" label="Until 2030" expiredMsg="It is 2030."}
::
## See also
- [Infoboxes and Spoilers](/sample/blocks/callouts)
- [Index and Include](/sample/blocks/navigation)
- [Back to the sample home](/sample/home)
`
},
{
path: 'sample/blocks/navigation',
title: 'Index and Include',
description: 'Listing the pages under a folder, and pulling one page into another.',
icon: 'mdi:file-tree-outline',
tags: ['blocks'],
content: `# Index and Include
## An index of a folder
Everything filed under the guides, two levels deep:
::block-index{path="sample/guides" depth="2" columns="2" showIcons="true"}
::
## An index by tag
Every page in this sample set carries the \`test\` tag, which is also what the purge action looks for:
::block-index{tags="blocks" limit="10" orderBy="title"}
::
## Including another page
The glossary, rendered inside this one:
::block-include{path="sample/reference/glossary" showTitle="true"}
::
## See also
- [Links, Images and Footnotes](/sample/formatting/media)
- [Back to the sample home](/sample/home)
`
},
{
path: 'sample/guides/getting-started/installation',
title: 'Installation',
description: 'What the wiki needs before it will start.',
icon: 'mdi:download',
tags: ['guide'],
content: `# Installation
## Requirements
| Component | Version |
| ---------- | --------- |
| Node.js | 26 or later |
| PostgreSQL | 16 or later |
## Steps
1. Install the dependencies in each workspace they are installed separately, and there is no root
package.
2. Copy \`config.sample.yml\` to \`config.yml\` and point it at your database.
3. Build the frontend, which is what the backend serves.
4. Start the backend from the repository root.
\`\`\`bash
cd backend && npm install
cd ../frontend && npm install && npm run build
cd .. && node backend
\`\`\`
> [!TIP] The dev container does all of this
> Open the repository in the dev container and it installs everything, brings up PostgreSQL, pgAdmin
> and a mail server, and leaves you at a prompt.
## Next
- [Configuration](/sample/guides/getting-started/configuration)
- [Your First Page](/sample/guides/getting-started/first-page)
- [Back to the sample home](/sample/home)
`
},
{
path: 'sample/guides/getting-started/configuration',
title: 'Configuration',
description: 'Where settings live, and which of them are files.',
icon: 'mdi:cog-outline',
tags: ['guide'],
content: `# Configuration
Settings come from three places, merged in this order:
1. \`base.yml\` — the defaults for every key, which defines the shape.
2. \`config.yml\` — what this instance overrides, and the only one an operator edits.
3. The \`settings\` table — everything the admin area writes.
::block-infobox{name="config.yml"}
\`\`\`yaml
Read at: boot
Also read by: the frontend dev server
Holds: port, database, data path
Never holds: anything the admin area can change
\`\`\`
::
## What belongs where
A value an operator sets before the wiki starts belongs in \`config.yml\`. A value an administrator
changes while it is running belongs in the database, because changing it must not need a restart.
> [!WARNING]
> \`base.yml\` is not a user-facing config. It defines the shape of what the other two merge into.
## Next
- [Your First Page](/sample/guides/getting-started/first-page)
- [Storage Targets](/sample/guides/advanced/storage)
- [Back to the sample home](/sample/home)
`
},
{
path: 'sample/guides/getting-started/first-page',
title: 'Your First Page',
description: 'Writing, saving and publishing.',
icon: 'mdi:file-document-edit-outline',
tags: ['guide'],
content: `# Your First Page
## Choose an editor
:::block-tabs
::block-tab{label="Markdown"}
The default. The source is markdown, and the editor renders a live preview beside it.
::
::block-tab{label="Visual"}
A WYSIWYG editor that stores HTML.
::
::block-tab{label="Redirect"}
Not a document at all a page whose only content is where it points.
::
:::
## Save
A save asks for a reason, which is recorded on the version rather than on the page. That is what a
history timeline is made of.
- [x] Give the page a title
- [x] Write something
- [ ] Add it to the navigation
- [ ] Tell somebody about it
## Next
- [Permissions](/sample/guides/advanced/permissions)
- [Search](/sample/guides/advanced/search)
- [Back to the sample home](/sample/home)
`
},
{
path: 'sample/guides/advanced/permissions',
title: 'Permissions',
description: 'The two kinds, and why they are not interchangeable.',
icon: 'mdi:shield-key-outline',
tags: ['guide'],
content: `# Permissions
There are two kinds, granted separately and checked in different places.
## Global permissions
Held site-wide, bound to no path. \`access:admin\`, \`manage:users\`, \`manage:groups\`,
\`manage:navigation\`, \`manage:theme\`, \`manage:sites\`, \`manage:system\`. That list is the whole of it.
\`manage:system\` bypasses every check everywhere.
## Page rule permissions
Bound to paths, and to locales and sites. A group grants them through **rules**: each rule names some
permissions, says how it addresses pages, and says what it does with them.
| Mode | What it means |
| ------------ | -------------------------------------------- |
| \`ALLOW\` | Grant these, unless something more specific denies |
| \`DENY\` | Refuse these |
| \`FORCEALLOW\` | Grant these, and let nothing override it |
> [!IMPORTANT]
> A page permission cannot be enforced by a route-level check that reads the group-wide list only,
> so declaring one there refuses everybody.
::block-spoiler{label="Which kind is \`manage:pages\`?" hint="Click to check yourself"}
A page rule permission. It does not imply \`write:pages\` either — a rule grants the exact strings it
names.
::
## See also
- [Storage Targets](/sample/guides/advanced/storage)
- [Glossary](/sample/reference/glossary)
- [Back to the sample home](/sample/home)
`
},
{
path: 'sample/guides/advanced/storage',
title: 'Storage Targets',
description: 'Where a page goes when it is saved.',
icon: 'mdi:database-outline',
tags: ['guide'],
content: `# Storage Targets
Content is **written** to every target that claims it, and **read** from one. Those are two separate
questions with two separate answers.
::block-diagram{caption="One upload, several destinations"}
\`\`\`mermaid
flowchart LR
U[Upload] --> S{Which targets claim this type?}
S --> DB[(Database)]
S --> D[Disk]
S --> G[Git]
DB --> R[Served to readers]
\`\`\`
::
## The targets that ship
| Key | What it is |
| ------- | ------------------------------------------------- |
| \`db\` | Bytes in the asset's own row. Always on. |
| \`disk\` | The wiki's tree as files |
| \`git\` | That same tree, with history and a remote |
| \`s3\` | S3, and anything speaking its API |
| \`azure\` | Azure Blob Storage |
| \`gcs\` | Google Cloud Storage |
| \`sftp\` | The tree on another host. A copy, never a source. |
> [!CAUTION]
> A pull from a git remote is authoritative, and that includes deletions. Push access to the remote
> is effectively write access to the wiki.
## See also
- [Configuration](/sample/guides/getting-started/configuration)
- [Search](/sample/guides/advanced/search)
- [Back to the sample home](/sample/home)
`
},
{
path: 'sample/guides/advanced/search',
title: 'Search',
description: 'What is indexed, and when.',
icon: 'mdi:magnify',
tags: ['guide'],
content: `# Search
A page is indexed from its rendered HTML rather than its source, which is why a block's output is
searchable and its \`::block-name\` line is not.
1. The page is saved.
2. Its render is reduced to plain search text.
3. The row is written to the index.
::block-index{path="sample/reference" columns="1" showIcons="true"}
::
## See also
- [Permissions](/sample/guides/advanced/permissions)
- [API Reference](/sample/reference/api)
- [Back to the sample home](/sample/home)
`
},
{
path: 'sample/reference/glossary',
title: 'Glossary',
description: 'The words this wiki uses for its own parts.',
icon: 'mdi:book-alphabet',
tags: ['reference'],
content: `# Glossary
Asset
: Any uploaded file. Where its bytes live is decided by the site's storage targets, not by the asset.
Block
: A web component embedded in a page with \`::block-name\`. Its code is fetched only when its tag turns
up in a page.
Folder
: A branch of the tree. It holds pages and other folders, and is not itself a page.
Page rule
: How a group grants the permissions that are bound to paths. See
[Permissions](/sample/guides/advanced/permissions).
Storage target
: One storage module configured for one site. See
[Storage Targets](/sample/guides/advanced/storage).
Tree
: The structure of the wiki what is filed where. A page is served from its own row and only located
through the tree.
`
},
{
path: 'sample/reference/api',
title: 'API Reference',
description: 'A worked example of the REST API, and where the real documentation lives.',
icon: 'mdi:api',
tags: ['reference'],
content: `# API Reference
The whole API is browsable at \`/_api\` in a running instance, generated from the route schemas
themselves so it is never out of date with the server answering it.
## Authenticating
Session cookie for a browser, bearer token for everything else.
\`\`\`bash
curl -H "Authorization: Bearer $WIKI_API_KEY" https://wiki.example.com/_api/sites
\`\`\`
## Creating a page
\`\`\`js title="create-page.mjs"
const res = await fetch(\`/_api/sites/\${'$'}{siteId}/pages\`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
path: 'notes/first',
title: 'My first page',
editor: 'markdown',
content: '# Hello',
render: '<h1>Hello</h1>',
tags: ['test']
})
})
\`\`\`
> [!NOTE]
> \`content\` is the source and \`render\` is the HTML produced from it. The server sanitizes the render
> against what the author is allowed to embed, so read the response rather than assuming what was
> sent is what was stored.
## See also
- [Search](/sample/guides/advanced/search)
- [Changelog](/sample/reference/changelog)
- [Back to the sample home](/sample/home)
`
},
{
path: 'sample/reference/changelog',
title: 'Changelog',
description: 'A page of nothing but lists and dates, for checking vertical rhythm.',
icon: 'mdi:history',
tags: ['reference'],
content: `# Changelog
## 3.0.0 unreleased
### Added
- Self-registration, email confirmation and password reset on the login screen
- A mail transport, and a test button in the admin area
- Sample content generation, which is what wrote this page
### Changed
- The login identifier now declares \`autocomplete="username"\` rather than \`email\`
- Pages hold their own render, sanitized against the author's permissions
### Fixed
- Enforced two-factor authentication, which read the wrong property and did nothing
- Dark mode on the login screen, where nothing set a foreground colour
## 2.5.308
### Fixed
- Various
---
*This page is fictional. It exists so there is something with a lot of short list items in it.*
`
}
]

@ -360,6 +360,7 @@ import { onMounted, reactive } from 'vue'
import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
import { useAdminStore } from '@/stores/admin'
import { useFlagsStore } from '@/stores/flags'
@ -486,13 +487,39 @@ function editTemplate(tmplId) {
})
}
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).
notify({
type: 'warning',
message: t('admin.mail.sendTestUnavailable')
})
async function sendTest() {
if (!state.testEmail) {
notify({
type: 'negative',
message: t('admin.mail.testRecipientMissing')
})
return
}
state.testLoading = true
try {
// -> The stored configuration is what it sends through, so anything typed above and not yet
// saved is not what is being tested
const resp = await API_CLIENT.post('mail/test', {
json: {
recipient: state.testEmail
}
}).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
notify({
type: 'positive',
message: t('admin.mail.sendTestSuccess')
})
} catch (err) {
notify({
type: 'negative',
message: t('admin.mail.sendTestFailed'),
// -> The mail server's own complaint, which is the whole value of the button
caption: apiErrorMessage(err)
})
}
state.testLoading = false
}
// MOUNTED

@ -7,7 +7,9 @@
src="/_assets/icons/fluent-swiss-army-knife-animated.svg" />
</div>
<div class="min-w-0 flex-1 pl-4">
<div class="text-h5 admin-page-title animated fadeInLeft">{{ t('admin.utilities.title') }}</div>
<div class="text-h5 admin-page-title animated fadeInLeft">
{{ t('admin.utilities.title') }}
</div>
<div class="text-subtitle1 text-grey animated fadeInLeft wait-p2s">
{{ t('admin.utilities.subtitle') }}
</div>
@ -77,6 +79,23 @@
:label="t(`common.actions.proceed`)" />
</w-item-section>
</w-item>
<w-item>
<blueprint-icon icon="popcorn-maker" :hue-rotate="45" />
<w-item-section>
<w-item-label>{{ t(`admin.utilities.generateSample`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.utilities.generateSampleHint`) }}</w-item-label>
</w-item-section>
<w-item-section side>
<w-btn
class="acrylic-btn"
flat
icon="la:arrow-circle-right"
color="primary"
:loading="state.sampleLoading"
@click="generateSampleContent"
:label="t(`common.actions.proceed`)" />
</w-item-section>
</w-item>
<w-item>
<blueprint-icon icon="database-restore" :hue-rotate="45" />
<w-item-section>
@ -173,6 +192,23 @@
:label="t(`common.actions.proceed`)" />
</w-item-section>
</w-item>
<w-item>
<blueprint-icon icon="eraser" :hue-rotate="45" />
<w-item-section>
<w-item-label>{{ t(`admin.utilities.purgeSample`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.utilities.purgeSampleHint`) }}</w-item-label>
</w-item-section>
<w-item-section side>
<w-btn
class="acrylic-btn"
flat
icon="la:arrow-circle-right"
color="primary"
:loading="state.sampleLoading"
@click="purgeSampleContent"
:label="t(`common.actions.proceed`)" />
</w-item-section>
</w-item>
<w-item>
<blueprint-icon icon="rescan-document" :hue-rotate="45" />
<w-item-section>
@ -205,10 +241,14 @@ import { loading } from '@/composables/loading'
import { confirm } from '@/composables/dialog'
import { apiErrorMessage } from '@/helpers/apiError'
import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site'
import { MarkdownRenderer } from '@/renderers/markdown'
// STORES
const adminStore = useAdminStore()
const siteStore = useSiteStore()
// I18N
@ -224,11 +264,18 @@ useMeta({
// DATA
const state = reactive({
purgeHistoryTimeframe: '1y'
purgeHistoryTimeframe: '1y',
/** Shared by both sample-content buttons: neither should be pressable while the other is running. */
sampleLoading: false
})
// COMPUTED
/** What to call the site the two sample-content actions write to and clear. */
const siteName = computed(
() => adminStore.sites.find((site) => site.id === adminStore.currentSiteId)?.title ?? ''
)
const purgeHistoryTimeframes = computed(() => [
{ value: '24h', label: t('admin.utitilies.purgeHistoryToday') },
{ value: '1m', label: t('admin.utitilies.purgeHistoryMonth', 1, { count: 1 }) },
@ -436,6 +483,170 @@ function purgeRevokedKeys() {
})
}
/**
* Fill the current site with pages to look at.
*
* A development convenience: a fresh instance is empty, so checking a stylesheet, a renderer or the
* navigation against anything means writing dummy pages first. Every page is tagged so
* {@link purgeSampleContent} can take them all away again.
*
* The pages are written one at a time through the ordinary create endpoint the same one the editor
* saves through rather than by a bulk call on the server. That is what makes the content
* representative: it goes through the same validation, the same render sanitising and the same tree
* placement as a page somebody typed.
*
* **The render is produced here**, by the same markdown renderer the editor uses, because that is
* where it lives. A page stores the HTML its editor produced, and the server can only produce one by
* driving a headless browser through the Puppeteer extension which a plain checkout does not
* install, so a server-side generator would write pages that are blank until somebody re-renders
* them.
*/
async function generateSampleContent() {
const { SAMPLE_CONTENT_TAG, SAMPLE_PAGES } = await import('@/helpers/sampleContent')
confirm({
title: t('admin.utilities.generateSample'),
message: t('admin.utilities.generateSampleConfirm', {
count: SAMPLE_PAGES.length,
site: siteName.value
}),
caption: t('admin.utilities.generateSampleConfirmWarn', { tag: SAMPLE_CONTENT_TAG }),
cancel: true,
persistent: true,
okLabel: t('common.actions.proceed')
}).onOk(async () => {
const siteId = adminStore.currentSiteId
state.sampleLoading = true
try {
/*
The site's own markdown settings, so the render matches what its editor would have produced
line breaks, typography and linkify all change the output. Read from the site rather than from
`editorStore`, which holds the config of the site being BROWSED, and the admin area may be
working on another one.
*/
const site = await API_CLIENT.get(`sites/${siteId}`).json()
const md = new MarkdownRenderer(site?.editors?.markdown?.config ?? {})
/*
Store the icons before the pages that use them, so the wiki can serve them without the Iconify
API afterwards which is what the icon picker does when an author chooses one.
The ones written INTO the content as well as the ones on the pages: a tab's `icon` prop is an
icon reference like any other, and materializing only the page icons left every tab in the
sample set with a blank space where its icon should be.
Best effort: this reaches upstream, which an offline instance does not, and an icon that could
not be fetched costs a missing picture rather than a page.
*/
const icons = new Set(SAMPLE_PAGES.map((page) => page.icon))
for (const page of SAMPLE_PAGES) {
for (const [, name] of page.content.matchAll(/\bicon="([a-z0-9-]+:[a-z0-9-]+)"/g)) {
icons.add(name)
}
}
try {
await API_CLIENT.post('icons/materialize', { json: { icons: [...icons] } }).json()
} catch (err) {
console.warn(`Could not store the sample content icons: ${apiErrorMessage(err)}`)
}
let created = 0
const failures = []
for (const page of SAMPLE_PAGES) {
try {
const resp = await API_CLIENT.post(`sites/${siteId}/pages`, {
json: {
path: page.path,
title: page.title,
description: page.description,
icon: page.icon,
editor: 'markdown',
content: page.content,
render: md.render(page.content, { pagePath: page.path }),
// -> The tag the purge looks for, first, then whatever this page is about
tags: [SAMPLE_CONTENT_TAG, ...page.tags],
publishState: 'published'
}
}).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
created++
} catch (err) {
// -> One page at a time, and one failure does not stop the rest: a path already taken is
// the likely case, and the other twenty pages are still worth having
failures.push(`${page.path}${apiErrorMessage(err)}`)
}
}
if (failures.length > 0) {
notify({
type: created > 0 ? 'warning' : 'negative',
message: t('admin.utilities.generateSamplePartial', created, { count: created }),
caption: failures.slice(0, 3).join('; ')
})
} else {
notify({
type: 'positive',
message: t('admin.utilities.generateSampleSuccess', created, { count: created })
})
}
} catch (err) {
notify({
type: 'negative',
message: t('admin.utilities.generateSampleFailed'),
caption: apiErrorMessage(err)
})
}
state.sampleLoading = false
})
}
/**
* Delete every page carrying the sample content tag.
*
* The tag is the whole of what is consulted, so a page tagged by hand goes with them which the
* confirmation says outright, since it is the one way this can take something nobody generated.
* Folders are left standing: they carry no tags, and one somebody made themselves must not go because
* a sample page happened to be filed in it.
*/
async function purgeSampleContent() {
const { SAMPLE_CONTENT_TAG } = await import('@/helpers/sampleContent')
confirm({
title: t('admin.utilities.purgeSample'),
message: t('admin.utilities.purgeSampleConfirm', {
tag: SAMPLE_CONTENT_TAG,
site: siteName.value
}),
caption: t('admin.utilities.purgeSampleConfirmWarn', { tag: SAMPLE_CONTENT_TAG }),
cancel: true,
persistent: true,
color: 'negative',
okLabel: t('common.actions.proceed')
}).onOk(async () => {
state.sampleLoading = true
try {
const resp = await API_CLIENT.post('system/sample-content/purge', {
json: { siteId: adminStore.currentSiteId }
}).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
const count = resp.count ?? 0
notify({
type: 'positive',
message: t('admin.utilities.purgeSampleSuccess', count, { count })
})
} catch (err) {
notify({
type: 'negative',
message: t('admin.utilities.purgeSampleFailed'),
caption: apiErrorMessage(err)
})
}
state.sampleLoading = false
})
}
/**
* Throw away everything the wiki has cached off the database files, icons, and the site, group and
* locale state read on every request. Not confirmed: nothing is lost and nothing stops working, the

@ -3,7 +3,8 @@
<div class="auth-content">
<div class="auth-logo"><img :src="`/_site/current/logo`" :alt="siteStore.title" /></div>
<h2 class="auth-site-title" v-if="siteStore.logoText">{{ siteStore.title }}</h2>
<p class="text-grey-7">Login to continue</p>
<!-- -> The panel says what each of its screens is for; a subtitle here would sit above all of
them and only be true of the first -->
<auth-login-panel />
</div>
<div class="auth-bg" aria-hidden="true"><img :src="`/_site/current/loginBg`" alt="" /></div>
@ -12,13 +13,11 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { onMounted, reactive, watch } from 'vue'
import { useMeta } from '@/composables/meta'
import { useSiteStore } from '@/stores/site'
import Cookies from 'js-cookie'
import AuthLoginPanel from '@/components/AuthLoginPanel.vue'
// STORES
@ -34,384 +33,24 @@ const { t } = useI18n()
useMeta({
title: t('auth.login.title')
})
// DATA
const state = reactive({
bgUrl: '_assets/bg/login-v3.jpg'
})
// isSocialShown () {
// return this.strategies.length > 1
// }
// filteredStrategies () {
// const qParams = new URLSearchParams(!import.meta.env.SSR ? window.location.search : '')
// if (this.hideLocal && !qParams.has('all')) {
// return reject(this.strategies, ['key', 'local'])
// } else {
// return this.strategies
// }
// }
// isUsernameEmail () {
// return this.selectedStrategy.strategy.usernameType === 'email'
// }
// filteredStrategies (newValue, oldValue) {
// if (head(newValue).strategy.useForm) {
// this.selectedStrategyKey = head(newValue).key
// }
// }
// selectedStrategyKey (newValue, oldValue) {
// this.selectedStrategy = find(this.strategies, ['key', newValue])
// if (this.screen === 'changePwd') {
// return
// }
// this.screen = 'login'
// if (!this.selectedStrategy.strategy.useForm) {
// this.isLoading = true
// window.location.assign('/login/' + newValue)
// } else {
// this.$nextTick(() => {
// this.$refs.iptEmail.focus()
// })
// }
// }
// mounted () {
// this.isShown = true
// if (this.changePwdContinuationToken) {
// this.screen = 'changePwd'
// this.continuationToken = this.changePwdContinuationToken
// }
// }
// METHODS
/**
* LOGIN
*/
async function login() {
this.errorShown = false
if (this.username.length < 2) {
this.errorMessage = t('auth.invalidEmailUsername')
this.errorShown = true
this.$refs.iptEmail.focus()
} else if (this.password.length < 2) {
this.errorMessage = t('auth.invalidPassword')
this.errorShown = true
this.$refs.iptPassword.focus()
} else {
this.loaderColor = 'grey darken-4'
this.loaderTitle = t('auth.signingIn')
this.isLoading = true
try {
const resp = await this.$apollo.mutate({
mutation: `
mutation($username: String!, $password: String!, $strategy: String!) {
authentication {
login(username: $username, password: $password, strategy: $strategy) {
responseResult {
succeeded
errorCode
slug
message
}
jwt
mustChangePwd
mustProvideTFA
mustSetupTFA
continuationToken
redirect
tfaQRImage
}
}
}
`,
variables: {
username: this.username,
password: this.password,
strategy: this.selectedStrategy.key
}
})
if (resp?.data?.authentication?.login) {
const respObj = resp?.data?.authentication?.login ?? {}
if (respObj.responseResult.succeeded === true) {
this.handleLoginResponse(respObj)
} else {
throw new Error(respObj.responseResult.message)
}
} else {
throw new Error(t('auth.genericError'))
}
} catch (err) {
console.error(err)
this.$q.notify({
type: 'negative',
message: err.message
})
this.isLoading = false
}
}
}
/**
* VERIFY TFA CODE
*/
async function verifySecurityCode(setup = false) {
if (this.securityCode.length !== 6) {
this.$store.commit('showNotification', {
style: 'red',
message: 'Enter a valid security code.',
icon: 'alert'
})
if (setup) {
this.$refs.iptTFASetup.focus()
} else {
this.$refs.iptTFA.focus()
}
} else {
this.loaderColor = 'grey darken-4'
this.loaderTitle = t('auth.signingIn')
this.isLoading = true
try {
const resp = await this.$apollo.mutate({
mutation: `
mutation(
$continuationToken: String!
$securityCode: String!
$setup: Boolean
) {
authentication {
loginTFA(
continuationToken: $continuationToken
securityCode: $securityCode
setup: $setup
) {
responseResult {
succeeded
errorCode
slug
message
}
jwt
mustChangePwd
continuationToken
redirect
}
}
}
`,
variables: {
continuationToken: this.continuationToken,
securityCode: this.securityCode,
setup
}
})
if (resp?.data?.authentication?.loginTFA) {
const respObj = resp?.data?.authentication?.loginTFA ?? {}
if (respObj.responseResult.succeeded === true) {
this.handleLoginResponse(respObj)
} else {
if (!setup) {
this.isTFAShown = false
}
throw new Error(respObj.responseResult.message)
}
} else {
throw new Error(t('auth.genericError'))
}
} catch (err) {
console.error(err)
this.$q.notify({
type: 'negative',
message: err.message
})
this.isLoading = false
}
}
}
/**
* CHANGE PASSWORD
*/
async function changePassword() {
this.loaderColor = 'grey darken-4'
this.loaderTitle = t('auth.changePwd.loading')
this.isLoading = true
try {
const resp = await this.$apollo.mutate({
mutation: `
mutation (
$continuationToken: String!
$newPassword: String!
) {
authentication {
loginChangePassword (
continuationToken: $continuationToken
newPassword: $newPassword
) {
responseResult {
succeeded
errorCode
slug
message
}
jwt
continuationToken
redirect
}
}
}
`,
variables: {
continuationToken: this.continuationToken,
newPassword: this.newPassword
}
})
if (resp?.data?.authentication?.loginChangePassword) {
const respObj = resp?.data?.authentication?.loginChangePassword ?? {}
if (respObj.responseResult.succeeded === true) {
this.handleLoginResponse(respObj)
} else {
throw new Error(respObj.responseResult.message)
}
} else {
throw new Error(t('auth.genericError'))
}
} catch (err) {
console.error(err)
this.$store.commit('showNotification', {
style: 'red',
message: err.message,
icon: 'alert'
})
this.isLoading = false
}
}
/**
* SWITCH TO FORGOT PASSWORD SCREEN
*/
function forgotPassword() {
this.screen = 'forgot'
this.$nextTick(() => {
this.$refs.iptForgotPwdEmail.focus()
})
}
/**
* FORGOT PASSWORD SUBMIT
*/
async function forgotPasswordSubmit() {
this.loaderColor = 'grey darken-4'
this.loaderTitle = t('auth.forgotPasswordLoading')
this.isLoading = true
try {
const resp = await this.$apollo.mutate({
mutation: `
mutation (
$email: String!
) {
authentication {
forgotPassword (
email: $email
) {
responseResult {
succeeded
errorCode
slug
message
}
}
}
}
`,
variables: {
email: this.username
}
})
if (resp?.data?.authentication?.forgotPassword?.responseResult) {
const respObj = resp?.data?.authentication?.forgotPassword?.responseResult ?? {}
if (respObj.succeeded === true) {
this.$store.commit('showNotification', {
style: 'success',
message: t('auth.forgotPasswordSuccess'),
icon: 'email'
})
this.screen = 'login'
} else {
throw new Error(respObj.message)
}
} else {
throw new Error(t('auth.genericError'))
}
} catch (err) {
console.error(err)
this.$store.commit('showNotification', {
style: 'red',
message: err.message,
icon: 'alert'
})
}
this.isLoading = false
}
function handleLoginResponse(respObj) {
this.continuationToken = respObj.continuationToken
if (respObj.mustChangePwd === true) {
this.screen = 'changePwd'
this.$nextTick(() => {
this.$refs.iptNewPassword.focus()
})
this.isLoading = false
} else if (respObj.mustProvideTFA === true) {
this.securityCode = ''
this.isTFAShown = true
setTimeout(() => {
this.$refs.iptTFA.focus()
}, 500)
this.isLoading = false
} else if (respObj.mustSetupTFA === true) {
this.securityCode = ''
this.isTFASetupShown = true
this.tfaQRImage = respObj.tfaQRImage
setTimeout(() => {
this.$refs.iptTFASetup.focus()
}, 500)
this.isLoading = false
} else {
this.loaderColor = 'green darken-1'
this.loaderTitle = t('auth.loginSuccess')
Cookies.set('jwt', respObj.jwt, { expires: 365 })
setTimeout(() => {
const loginRedirect = Cookies.get('loginRedirect')
if (loginRedirect === '/' && respObj.redirect) {
Cookies.remove('loginRedirect')
window.location.replace(respObj.redirect)
} else if (loginRedirect) {
Cookies.remove('loginRedirect')
window.location.replace(loginRedirect)
} else if (respObj.redirect) {
window.location.replace(respObj.redirect)
} else {
window.location.replace('/')
}
}, 1000)
}
}
onMounted(() => {
// fetchStrategies()
})
</script>
<style lang="scss">
.auth {
background-color: #fff;
/*
The foreground the background needs, and the reason it has to be said here: the only thing in the
app that turns text white in dark mode is `.w-card`, and this screen is not in a card the panel
sits straight on the page. So the surface went dark and everything on it that had not named its
own colour stayed black: the subtitle under each screen's heading, the icons at the head of every
field, and the value being typed into them, which is a text input inheriting from here.
*/
color: var(--color-black);
display: flex;
@at-root .body--dark & {
background-color: $dark-6;
color: var(--color-white);
}
&-content {

Loading…
Cancel
Save