feat: profile auth + approvals manage/submit (wip)

scarlett
NGPixel 1 month ago
parent 072e1dcc42
commit 957efebecb
No known key found for this signature in database

@ -364,13 +364,12 @@ An earlier iteration of 3.x used GraphQL/Apollo. **All of it is deprecated** —
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`.
Seven files under `frontend/src/` make live `APOLLO_CLIENT` calls, and each needs a REST endpoint
Four 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:
| File | Feature |
| ---- | ------- |
| `components/AuthLoginPanel.vue` | passkey login, self-registration, TFA verify + setup |
| `components/ChangePwdDialog.vue`, `pages/ProfileAuth.vue`, `components/SetupTfaDialog.vue` | password / TFA self-service |
| `components/AuthLoginPanel.vue` | self-registration (the `register()` call only — passkey login and 2FA are REST now) |
| `pages/AdminGeneral.vue`, `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)

@ -0,0 +1,523 @@
import { CustomError } from '../helpers/common.ts'
import { actorFrom, mayBypassPassword, unlockedFor } from './pages.ts'
import type { ApprovalPageRef, ApprovalRulePatch } from '../models/approvals.ts'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
/**
* The page a suggestion is about, with the source it would be edited from.
*
* Loaded the way the public page route loads it an anonymous reader sees published pages only, and a
* password still has to have been entered so eligibility to suggest an edit never becomes a way to
* read something that was not readable. The source itself is fetched regardless of who is asking,
* because the caller has to be able to edit what they are looking at; the routes below only hand it
* over once a rule says this actor may suggest edits to this page.
*/
async function loadSuggestablePage(req: FastifyRequest, siteId: string, pageId: string) {
const actor = actorFrom(req)
return WIKI.models.pages.getPage({
siteId,
id: pageId,
withContent: true,
publicOnly: !actor,
unlocked: (id: string) => unlockedFor(req, id),
withPassword: mayBypassPassword(req)
})
}
/**
* Everything a rule has to satisfy beyond what the JSON Schema already enforces.
*
* All of it comes down to the same thing: a rule that cannot match a page, or that nobody is on either
* side of, is a rule that does nothing, and storing one silently is worse than refusing it.
*
* @returns A `CustomError` to throw, or null when the rule is usable
*/
function validateRule({
name,
match,
path,
submitterGroups,
reviewerGroups
}: {
name: string
match: string
path: string
submitterGroups: string[]
reviewerGroups: string[]
}): CustomError | null {
if (!name || name.trim().length < 1) {
return new CustomError('approvalRuleEmptyName', 'A rule name is required.')
}
if (!path || path.trim().length < 1) {
return new CustomError(
'approvalRuleEmptyPath',
match === 'TAG' || match === 'TAGALL'
? 'At least one tag is required.'
: 'A path is required.'
)
}
if (match === 'REGEX') {
try {
new RegExp(path)
} catch (err: any) {
return new CustomError(
'approvalRuleInvalidRegex',
`Not a valid regular expression: ${err.message}`
)
}
}
if (submitterGroups.length < 1) {
return new CustomError(
'approvalRuleNoSubmitters',
'At least one group has to be able to submit edits.'
)
}
if (reviewerGroups.length < 1) {
return new CustomError(
'approvalRuleNoReviewers',
'At least one group has to review submissions.'
)
}
return null
}
/**
* Reject group IDs that are not groups on this instance, for either list.
*
* @returns Whether the reply has been sent
*/
async function rejectUnknownGroups(
reply: FastifyReply,
groupIds: (string[] | undefined)[]
): Promise<boolean> {
const unknown = await WIKI.models.approvals.getUnknownGroupIds(
groupIds.flatMap((ids) => ids ?? [])
)
if (unknown.length > 0) {
reply.badRequest(`No such group: ${unknown.join(', ')}`)
return true
}
return false
}
/**
* Approvals API Routes
*/
async function routes(app: FastifyInstance) {
/**
* LIST SITE APPROVAL RULES
*/
app.get<{ Params: { siteId: string } }>(
'/sites/:siteId/approvals/rules',
{
config: {
permissions: ['read:sites', 'manage:sites']
},
schema: {
summary: 'List the approval rules of a site',
description:
'Each rule says which pages accept edit suggestions, which groups may submit them, and which groups review them. A page matched by no rule accepts none, so a site with no rules has the feature off.',
tags: ['Approvals'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
},
response: {
200: {
description: 'List of approval rules',
type: 'array',
items: { $ref: 'ApprovalRule#' }
}
}
}
},
async (req, reply) => {
const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
if (!site) {
return reply.notFound('Site does not exist.')
}
return WIKI.models.approvals.getRules(req.params.siteId)
}
)
/**
* CREATE AN APPROVAL RULE
*/
app.post<{ Params: { siteId: string }; Body: ApprovalRulePatch }>(
'/sites/:siteId/approvals/rules',
{
config: {
permissions: ['manage:sites']
},
schema: {
summary: 'Create an approval rule',
description:
'Rules are not ordered: a page is covered when any rule matches it, so a new one only ever adds coverage.',
tags: ['Approvals'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
},
body: {
allOf: [
{ $ref: 'ApprovalRuleInput#' },
{ type: 'object', required: ['name', 'match', 'path'] }
]
},
response: {
200: {
description: 'Rule created successfully',
type: 'object',
properties: {
ok: { type: 'boolean' },
rule: { $ref: 'ApprovalRule#' }
}
}
}
}
},
async (req, reply) => {
const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
if (!site) {
return reply.notFound('Site does not exist.')
}
const invalid = validateRule({
name: req.body.name!,
match: req.body.match!,
path: req.body.path!,
submitterGroups: req.body.submitterGroups ?? [],
reviewerGroups: req.body.reviewerGroups ?? []
})
if (invalid) {
throw invalid
}
if (await rejectUnknownGroups(reply, [req.body.submitterGroups, req.body.reviewerGroups])) {
return reply
}
const rule = await WIKI.models.approvals.createRule(req.params.siteId, req.body)
return {
ok: true,
rule
}
}
)
/**
* UPDATE AN APPROVAL RULE
*/
app.put<{ Params: { siteId: string; ruleId: string }; Body: ApprovalRulePatch }>(
'/sites/:siteId/approvals/rules/:ruleId',
{
config: {
permissions: ['manage:sites']
},
schema: {
summary: 'Update an approval rule',
description: 'Accepts any subset of the fields; omitted ones are left unchanged.',
tags: ['Approvals'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
},
ruleId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId', 'ruleId']
},
body: { $ref: 'ApprovalRuleInput#' },
response: {
200: {
description: 'Rule updated successfully',
type: 'object',
properties: {
ok: { type: 'boolean' },
rule: { $ref: 'ApprovalRule#' }
}
}
}
}
},
async (req, reply) => {
const current = await WIKI.models.approvals.getRule(req.params.siteId, req.params.ruleId)
if (!current) {
return reply.notFound('Approval rule does not exist.')
}
if (Object.keys(req.body).length < 1) {
throw new CustomError('approvalRuleEmpty', 'No rule fields provided to update.')
}
// -> Validated as the rule will be, not as it was sent: changing the mode alone has to hold up
// against the stored path, and emptying one group list has to be caught even though the other
// was not touched
const invalid = validateRule({
name: req.body.name ?? current.name,
match: req.body.match ?? current.match,
path: req.body.path ?? current.path,
submitterGroups: req.body.submitterGroups ?? current.submitterGroups,
reviewerGroups: req.body.reviewerGroups ?? current.reviewerGroups
})
if (invalid) {
throw invalid
}
if (await rejectUnknownGroups(reply, [req.body.submitterGroups, req.body.reviewerGroups])) {
return reply
}
const rule = await WIKI.models.approvals.updateRule(
req.params.siteId,
req.params.ruleId,
req.body
)
if (!rule) {
return reply.notFound('Approval rule does not exist.')
}
return {
ok: true,
rule
}
}
)
/**
* DELETE AN APPROVAL RULE
*/
app.delete<{ Params: { siteId: string; ruleId: string } }>(
'/sites/:siteId/approvals/rules/:ruleId',
{
config: {
permissions: ['manage:sites']
},
schema: {
summary: 'Delete an approval rule',
description:
'The pages it covered stop accepting edit suggestions, unless another rule also matches them.',
tags: ['Approvals'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
},
ruleId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId', 'ruleId']
},
response: {
204: {
description: 'Rule deleted successfully'
}
}
}
},
async (req, reply) => {
if (!(await WIKI.models.approvals.deleteRule(req.params.siteId, req.params.ruleId))) {
return reply.notFound('Approval rule does not exist.')
}
return reply.code(204).send()
}
)
/**
* GET OWN SUGGESTION STATE FOR A PAGE
*
* Deliberately not permission-gated: whether somebody may suggest an edit is decided by the site's
* approval rules and the groups they are in, and for an anonymous reader those are the guests
* group's. A route permission would answer 401 before any of that could be considered.
*/
app.get<{
Params: { siteId: string; pageId: string }
Querystring: { withContent?: boolean }
}>(
'/sites/:siteId/pages/:pageId/suggestions/self',
{
schema: {
summary: 'Whether the caller may suggest edits to a page, and what they already suggested',
description:
"Answers `canSubmit: false` for a page no enabled rule opens to this reader, which is what hides the button. With `withContent`, also returns the source the editor should open with: the caller's own pending suggestion when they have one, so that they carry on where they left off, otherwise the page as it stands. The source is only ever included when `canSubmit` holds.",
tags: ['Approvals'],
params: {
type: 'object',
properties: {
siteId: { type: 'string', format: 'uuid' },
pageId: { type: 'string', format: 'uuid' }
},
required: ['siteId', 'pageId']
},
querystring: {
type: 'object',
properties: {
withContent: { type: 'boolean', default: false }
}
},
response: {
200: {
description: 'Suggestion state for the caller',
type: 'object',
properties: {
canSubmit: { type: 'boolean' },
isGuest: {
type: 'boolean',
description:
'True when nobody is logged in, in which case submitting has to carry a name and an email address.'
},
submission: {
type: ['object', 'null'],
properties: {
id: { type: 'string', format: 'uuid' },
updatedAt: { type: 'string', format: 'date-time' }
}
},
content: {
type: 'string',
description: 'Only present with `withContent`, and only when `canSubmit` holds.'
}
}
}
}
}
},
async (req, reply) => {
reply.preventCache()
const page = await loadSuggestablePage(req, req.params.siteId, req.params.pageId)
if (!page) {
return reply.notFound('This page does not exist.')
}
const actor = actorFrom(req)
const groupIds = WIKI.models.approvals.getActorGroupIds(req)
const pageRef: ApprovalPageRef = { id: page.id, path: page.path, tags: page.tags ?? [] }
const rule = await WIKI.models.approvals.findSubmitRule(req.params.siteId, pageRef, groupIds)
if (!rule) {
return { canSubmit: false, isGuest: !actor, submission: null }
}
const submission = await WIKI.models.approvals.getOwnSubmission(page.id, actor?.id ?? null)
return {
canSubmit: true,
isGuest: !actor,
submission: submission ? { id: submission.id, updatedAt: submission.updatedAt } : null,
...(req.query.withContent
? { content: submission ? submission.content : (page.content ?? '') }
: {})
}
}
)
/**
* SUBMIT AN EDIT SUGGESTION FOR A PAGE
*/
app.put<{
Params: { siteId: string; pageId: string }
Body: { content: string; guestName?: string; guestEmail?: string }
}>(
'/sites/:siteId/pages/:pageId/suggestions/self',
{
schema: {
summary: 'Submit an edit suggestion for a page',
description:
'Stores the suggested source together with a patch against the page as it stands, so that suggestions to different parts of a page can each be accepted later. A logged in author has one open suggestion per page and submitting again replaces it. An anonymous submitter has no account to attribute it to and has to give a name and an email address instead.',
tags: ['Approvals'],
params: {
type: 'object',
properties: {
siteId: { type: 'string', format: 'uuid' },
pageId: { type: 'string', format: 'uuid' }
},
required: ['siteId', 'pageId']
},
body: {
type: 'object',
required: ['content'],
properties: {
content: { type: 'string' },
guestName: { type: 'string', maxLength: 255 },
guestEmail: { type: 'string', maxLength: 255 }
}
},
response: {
200: {
description: 'Suggestion submitted successfully',
type: 'object',
properties: {
ok: { type: 'boolean' },
submission: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
updatedAt: { type: 'string', format: 'date-time' }
}
}
}
}
}
}
},
async (req, reply) => {
const page = await loadSuggestablePage(req, req.params.siteId, req.params.pageId)
if (!page) {
return reply.notFound('This page does not exist.')
}
const actor = actorFrom(req)
const groupIds = WIKI.models.approvals.getActorGroupIds(req)
const pageRef: ApprovalPageRef = { id: page.id, path: page.path, tags: page.tags ?? [] }
const rule = await WIKI.models.approvals.findSubmitRule(req.params.siteId, pageRef, groupIds)
if (!rule) {
return reply.forbidden('This page does not accept edit suggestions from you.')
}
const guestName = (req.body.guestName ?? '').trim()
const guestEmail = (req.body.guestEmail ?? '').trim()
if (!actor) {
// -> Nothing else records who this came from, and a reviewer has to be able to answer whoever
// sent it
if (guestName.length < 1) {
throw new CustomError('suggestionGuestNameMissing', 'A name is required.')
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(guestEmail)) {
throw new CustomError('suggestionGuestEmailInvalid', 'A valid email address is required.')
}
}
const submission = await WIKI.models.approvals.saveSubmission({
siteId: req.params.siteId,
page: pageRef,
baseContent: page.content ?? '',
content: req.body.content,
authorId: actor?.id ?? null,
guestName,
guestEmail
})
return {
ok: true,
submission: { id: submission.id, updatedAt: submission.updatedAt }
}
}
)
}
export default routes

@ -166,6 +166,9 @@ async function routes(app: FastifyInstance) {
maxLength: 255
}
}
},
response: {
200: { $ref: 'AuthLoginResult#' }
}
}
},
@ -242,6 +245,9 @@ async function routes(app: FastifyInstance) {
maxLength: 255
}
}
},
response: {
200: { $ref: 'AuthLoginResult#' }
}
}
},
@ -280,6 +286,229 @@ async function routes(app: FastifyInstance) {
}
)
/**
* SUBMIT A 2FA CODE
*
* The other half of a login that answered `provideTfa` or `setupTfa`: the continuation token stands
* for the login that got that far, and the code proves the second factor. With `setup`, a correct
* code also activates the secret the login generated, which is how an account that is required to
* use 2FA gets it configured.
*/
app.put<{
Params: { siteId: string }
Body: {
strategyId: string
continuationToken: string
securityCode: string
setup?: boolean
}
}>(
'/sites/:siteId/auth/tfa',
{
schema: {
summary: 'Submit a 2FA Security Code From Login',
description:
'Answers like the login route does, since the same checks continue afterwards: a user who also owes a password change is asked for one next. A wrong code can be retried a few times before the continuation token is discarded and the login has to be started again.',
tags: ['Authentication'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
},
body: {
type: 'object',
required: ['strategyId', 'continuationToken', 'securityCode'],
properties: {
strategyId: {
type: 'string',
format: 'uuid'
},
continuationToken: {
type: 'string',
minLength: 1,
maxLength: 255
},
securityCode: {
type: 'string',
pattern: '^[0-9]{6}$',
description: 'The six digits shown by the authenticator app.'
},
setup: {
type: 'boolean',
default: false,
description:
'True when answering a `setupTfa` login, i.e. the code confirms a secret that was just generated.'
}
}
},
response: {
200: { $ref: 'AuthLoginResult#' }
}
}
},
async (req, reply) => {
try {
const result = await WIKI.models.users.loginTFA(
{
siteId: req.params.siteId,
strategyId: req.body.strategyId,
continuationToken: req.body.continuationToken,
securityCode: req.body.securityCode,
setup: req.body.setup ?? false,
ip: req.ip
},
req
)
return {
ok: true,
...result
}
} catch (err: any) {
if (err.message.startsWith('ERR_')) {
WIKI.models.flags.authDebug(`2FA verification rejected: ${err.message}`)
return reply.badRequest(err.message)
} else {
WIKI.logger.debug(err)
WIKI.models.flags.authDebug(`2FA verification failed unexpectedly: ${err.message}`)
return reply.badRequest('ERR_TFA_FAILED')
}
}
}
)
/**
* REQUEST A PASSKEY CHALLENGE
*
* Takes no identity: a passkey says which account it belongs to, so there is nobody to name until the
* assertion comes back. The challenge is remembered on the session.
*/
app.post<{ Params: { siteId: string } }>(
'/sites/:siteId/auth/passkey/challenge',
{
schema: {
summary: 'Get the options for logging in with a passkey',
description:
"Pass the result to the browser's WebAuthn API, then send what the authenticator produces to `PUT /sites/:siteId/auth/passkey/login`. No credential list is sent and no user is named: passkeys are registered as discoverable credentials, so the authenticator offers whichever ones it holds for this hostname and the assertion identifies the account.",
tags: ['Authentication'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
},
response: {
200: {
description: 'Passkey challenge generated',
type: 'object',
properties: {
ok: { type: 'boolean' },
authOptions: {
type: 'object',
additionalProperties: true,
description: 'A WebAuthn `PublicKeyCredentialRequestOptions`, JSON-encoded.'
}
}
}
}
}
},
async (req, reply) => {
try {
const { authOptions, pending } = await WIKI.models.passkeys.startLogin({
hostname: req.hostname,
origin: req.headers.origin
})
req.session.passkeyLogin = pending
return {
ok: true,
authOptions
}
} catch (err: any) {
if (err.message.startsWith('ERR_')) {
return reply.badRequest(err.message)
} else {
WIKI.logger.debug(err)
return reply.badRequest('ERR_LOGIN_FAILED')
}
}
}
)
/**
* LOGIN USING A PASSKEY
*/
app.put<{ Params: { siteId: string }; Body: { authResponse: Record<string, any> } }>(
'/sites/:siteId/auth/passkey/login',
{
schema: {
summary: 'Login With a Passkey',
description:
'Verifies what the authenticator signed and, if it holds up, logs the user in. A passkey establishes both identity and presence, so no password or 2FA code is asked for on top of it.',
tags: ['Authentication'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
},
body: {
type: 'object',
required: ['authResponse'],
properties: {
authResponse: {
type: 'object',
additionalProperties: true,
description: "The browser's WebAuthn authentication response, JSON-encoded."
}
}
},
response: {
200: { $ref: 'AuthLoginResult#' }
}
}
},
async (req, reply) => {
try {
const result = await WIKI.models.passkeys.verifyLogin(
{
authResponse: req.body.authResponse as any,
pending: req.session.passkeyLogin,
ip: req.ip
},
req
)
return {
ok: true,
...result
}
} catch (err: any) {
if (err.message.startsWith('ERR_')) {
return reply.badRequest(err.message)
} else {
WIKI.logger.debug(err)
WIKI.models.flags.authDebug(`Passkey login failed unexpectedly: ${err.message}`)
return reply.badRequest('ERR_LOGIN_FAILED')
}
} finally {
// -> Spent either way: a rejected assertion does not get a second go at the same challenge
req.session.passkeyLogin = undefined
}
}
)
/**
* LOGOUT
*/

@ -6,6 +6,7 @@ import type { FastifyInstance } from 'fastify'
async function routes(app: FastifyInstance) {
// Register schemas
await import('./schemas/apiKey.ts').then((m) => m.registerSchemas(app))
await import('./schemas/approval.ts').then((m) => m.registerSchemas(app))
await import('./schemas/asset.ts').then((m) => m.registerSchemas(app))
await import('./schemas/authentication.ts').then((m) => m.registerSchemas(app))
await import('./schemas/block.ts').then((m) => m.registerSchemas(app))
@ -25,6 +26,7 @@ async function routes(app: FastifyInstance) {
// Register routes
app.register(import('./apiKeys.ts'), { prefix: '/api-keys' })
app.register(import('./approvals.ts'))
app.register(import('./assets.ts'))
app.register(import('./authentication.ts'))
app.register(import('./blocks.ts'))

@ -46,7 +46,7 @@ const pageIdParam = {
* A page records an author, so it takes a logged in user rather than an API key and the author's
* permissions are what the render is sanitized against.
*/
function actorFrom(req: FastifyRequest): PageActor | null {
export function actorFrom(req: FastifyRequest): PageActor | null {
if (!req.session?.authenticated || !req.session.user?.id) {
return null
}
@ -80,7 +80,7 @@ const PAGE_PERMISSIONS = [
'delete:pages'
]
function mayBypassPassword(req: FastifyRequest): boolean {
export function mayBypassPassword(req: FastifyRequest): boolean {
const permissions = req.apiKey?.permissions ?? req.session?.permissions ?? []
return PASSWORD_BYPASS.some((permission) => permissions.includes(permission))
}
@ -91,7 +91,7 @@ function mayBypassPassword(req: FastifyRequest): boolean {
* The unlock is recorded on the session server side, by page id so that reading a page the reader
* unlocked a moment ago does not ask again, and so that nothing the browser can set decides this.
*/
function unlockedFor(req: FastifyRequest, pageId: string): boolean {
export function unlockedFor(req: FastifyRequest, pageId: string): boolean {
return mayBypassPassword(req) || Boolean(req.session?.unlockedPages?.includes(pageId))
}

@ -0,0 +1,104 @@
import { approvalMatchModes } from '../../models/approvals.ts'
import type { FastifyInstance } from 'fastify'
export async function registerSchemas(app: FastifyInstance): Promise<void> {
/**
* APPROVAL RULE - Which pages accept edit suggestions, from whom, and who reviews them
*/
app.addSchema({
$id: 'ApprovalRule',
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid'
},
name: {
type: 'string',
description: 'What the rule is called in the admin list.'
},
isEnabled: {
type: 'boolean',
description: 'A disabled rule keeps its configuration but covers nothing.'
},
match: {
type: 'string',
enum: [...approvalMatchModes],
description:
'How `path` is compared: the same modes group page rules use. `TAG` matches a page carrying any of the listed tags, `TAGALL` one carrying all of them.'
},
path: {
type: 'string',
description:
'The pattern, without a leading slash. A comma-separated list of tags for the tag modes.'
},
submitterGroups: {
type: 'array',
description: 'IDs of the groups whose members may submit edit suggestions.',
items: {
type: 'string',
format: 'uuid'
}
},
reviewerGroups: {
type: 'array',
description:
'IDs of the groups that review those submissions, and are notified when one comes in.',
items: {
type: 'string',
format: 'uuid'
}
},
createdAt: {
type: 'string',
format: 'date-time',
description: 'RFC 3339 Date Time'
},
updatedAt: {
type: 'string',
format: 'date-time',
description: 'RFC 3339 Date Time'
}
}
})
/**
* APPROVAL RULE INPUT - The fields a rule is written with
*/
app.addSchema({
$id: 'ApprovalRuleInput',
type: 'object',
properties: {
name: {
type: 'string',
minLength: 1,
maxLength: 255
},
isEnabled: {
type: 'boolean'
},
match: {
type: 'string',
enum: [...approvalMatchModes]
},
path: {
type: 'string',
maxLength: 2048
},
submitterGroups: {
type: 'array',
items: {
type: 'string',
format: 'uuid'
}
},
reviewerGroups: {
type: 'array',
items: {
type: 'string',
format: 'uuid'
}
}
}
})
}

@ -1,6 +1,42 @@
import type { FastifyInstance } from 'fastify'
export async function registerSchemas(app: FastifyInstance): Promise<void> {
/**
* AUTH LOGIN RESULT - Where a login attempt got to, and what the client must do next
*/
app.addSchema({
$id: 'AuthLoginResult',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
authenticated: {
type: 'boolean',
description: 'Present, and true, only once the session is actually logged in.'
},
nextAction: {
type: 'string',
enum: ['redirect', 'changePassword', 'provideTfa', 'setupTfa'],
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`.'
},
continuationToken: {
type: 'string',
description: 'Stands for this half-finished login. Sent back with whatever it asked for.'
},
tfaQRImage: {
type: 'string',
description:
'For `setupTfa` only: the `otpauth://` URI as an SVG QR code, to be rendered as-is.'
},
redirect: {
type: 'string',
description: 'Where to send the user once logged in. A path within this wiki, or a URL.'
}
}
})
/**
* AUTH MODULE - An authentication module as found on disk
*/

@ -1,6 +1,34 @@
import type { FastifyInstance } from 'fastify'
export async function registerSchemas(app: FastifyInstance): Promise<void> {
/**
* PASSKEY - One registered authenticator, without any of its key material
*/
app.addSchema({
$id: 'Passkey',
type: 'object',
properties: {
id: {
type: 'string',
description: 'The WebAuthn credential ID, base64url-encoded.'
},
name: {
type: 'string',
description: 'What the user called it, e.g. the device it lives on.'
},
siteHostname: {
type: 'string',
description:
'The hostname it was registered against. A passkey only works on that host, so this is stored rather than resolved from the site, which may since have been renamed.'
},
createdAt: {
type: 'string',
format: 'date-time',
description: 'RFC 3339 Date Time'
}
}
})
/**
* USER CORE - Essential fields only
*/
@ -209,7 +237,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
auth: {
type: 'array',
description:
'Authentication providers linked to this user. Secrets are never included — `config.isPasswordSet` and `config.tfaIsActive` report their state instead.',
'Authentication providers linked to this user. Secrets are never included — `config.isPasswordSet` and `config.isTfaSetup` report their state instead.',
items: {
type: 'object',
properties: {

@ -1,4 +1,4 @@
import { CustomError } from '../helpers/common.ts'
import { CustomError, rethrowAsBadRequest } from '../helpers/common.ts'
import { detectImageMime, imageMimeTypes } from '../helpers/images.ts'
import type { FastifyInstance, FastifyRequest } from 'fastify'
import type { UserPatch, UserProfilePatch } from '../models/users.ts'
@ -512,6 +512,532 @@ async function routes(app: FastifyInstance) {
}
)
/**
* GET OWN AUTHENTICATION METHODS
*
* What the profile's authentication page is built from: the providers linked to the account and the
* passkeys registered against it. Session-scoped like the rest of `/profile` a user can only ever
* see its own, and no permission expresses that.
*/
app.get(
'/profile/auth',
{
schema: {
summary: "Get the logged in user's authentication methods",
description:
'The providers the account can be signed in with, plus its registered passkeys. Secrets are never included: each provider reports only whether a password is set, whether 2FA is active, and whether the user is allowed to turn it off.',
tags: ['Users'],
response: {
200: {
description: 'Authentication methods',
type: 'object',
properties: {
authMethods: {
type: 'array',
items: {
type: 'object',
properties: {
authId: { type: 'string', format: 'uuid' },
authName: { type: 'string' },
strategyKey: { type: 'string' },
strategyIcon: { type: 'string' },
config: {
type: 'object',
properties: {
isPasswordSet: { type: 'boolean' },
isTfaSetup: { type: 'boolean' },
isTfaRequired: {
type: 'boolean',
description:
'Either this user is flagged for 2FA or the strategy enforces it. Turning 2FA off is refused while this holds.'
},
isPasswordLoginEnabled: {
type: 'boolean',
description:
'False once password login has been turned off, by the user or by an administrator.'
},
canDisablePasswordLogin: {
type: 'boolean',
description:
'Whether the account has another way in — a passkey or another linked provider — and may therefore turn password login off.'
}
}
}
}
}
},
passkeys: {
type: 'array',
items: { $ref: 'Passkey#' }
}
}
}
}
}
},
async (req, reply) => {
reply.preventCache()
const userId = sessionUserId(req)
if (!userId) {
return reply.unauthorized()
}
return {
authMethods: await WIKI.models.users.getProfileAuthMethods(userId),
passkeys: await WIKI.models.passkeys.list(userId)
}
}
)
/**
* CHANGE OWN PASSWORD
*/
app.put<{ Body: { strategyId: string; currentPassword: string; newPassword: string } }>(
'/profile/password',
{
schema: {
summary: "Change the logged in user's own password",
description:
'The current password has to be given, and is what authorizes the change. Only a provider that stores the password on this instance can be changed here. Also clears any pending forced password change.',
tags: ['Users'],
body: {
type: 'object',
required: ['strategyId', 'currentPassword', 'newPassword'],
properties: {
strategyId: {
type: 'string',
format: 'uuid',
description: 'The provider whose password is being changed.'
},
currentPassword: { type: 'string', minLength: 1, maxLength: 255 },
newPassword: { type: 'string', minLength: 8, maxLength: 255 }
}
},
response: {
200: {
description: 'Password changed successfully',
type: 'object',
properties: {
ok: { type: 'boolean' },
message: { type: 'string' }
}
}
}
}
},
async (req, reply) => {
const userId = sessionUserId(req)
if (!userId) {
return reply.unauthorized()
}
try {
await WIKI.models.users.changeOwnPassword({
userId,
strategyId: req.body.strategyId,
currentPassword: req.body.currentPassword,
newPassword: req.body.newPassword
})
} catch (err: any) {
rethrowAsBadRequest(err)
}
return {
ok: true,
message: 'Password changed successfully.'
}
}
)
/**
* TURN OWN PASSWORD LOGIN ON OR OFF
*/
app.put<{ Body: { strategyId: string; isEnabled: boolean } }>(
'/profile/password-login',
{
schema: {
summary: "Turn password login on or off for the logged in user's own account",
description:
'The same restriction an administrator can apply from the admin area. Turning it off is refused unless the account has another way in — a registered passkey or another linked provider — so that a user cannot lock themselves out. The password itself is kept, so turning it back on restores it.',
tags: ['Users'],
body: {
type: 'object',
required: ['strategyId', 'isEnabled'],
properties: {
strategyId: {
type: 'string',
format: 'uuid',
description:
'The provider to change, which has to be one that stores a password here.'
},
isEnabled: { type: 'boolean' }
}
},
response: {
200: {
description: 'Password login setting updated successfully',
type: 'object',
properties: {
ok: { type: 'boolean' },
message: { type: 'string' }
}
}
}
}
},
async (req, reply) => {
const userId = sessionUserId(req)
if (!userId) {
return reply.unauthorized()
}
try {
await WIKI.models.users.setPasswordLoginEnabled({
userId,
strategyId: req.body.strategyId,
isEnabled: req.body.isEnabled
})
} catch (err: any) {
rethrowAsBadRequest(err)
}
return {
ok: true,
message: req.body.isEnabled ? 'Password login enabled.' : 'Password login disabled.'
}
}
)
/**
* START OWN 2FA SETUP
*
* Two steps, because the server cannot know the secret reached the user's authenticator until the
* user proves it did: this hands out a QR code and a continuation token, and `PUT` activates the
* secret once a code generated from it comes back.
*/
app.post<{ Body: { strategyId: string } }>(
'/profile/tfa',
{
schema: {
summary: "Start setting up 2FA on the logged in user's account",
description:
'Generates a secret and returns the QR code to scan. The secret does nothing until a code produced by it is submitted to `PUT /users/profile/tfa` with the continuation token returned here. Starting again replaces a secret that was never activated.',
tags: ['Users'],
body: {
type: 'object',
required: ['strategyId'],
properties: {
strategyId: { type: 'string', format: 'uuid' }
}
},
response: {
200: {
description: '2FA setup started',
type: 'object',
properties: {
ok: { type: 'boolean' },
continuationToken: { type: 'string' },
tfaQRImage: {
type: 'string',
description: 'The `otpauth://` URI as an SVG QR code, to be rendered as-is.'
},
tfaSecret: {
type: 'string',
description:
'The base32 secret the QR code encodes, for a user who would rather type it into an authenticator app than scan it. Only ever returned here, to the user setting 2FA up on their own account.'
}
}
}
}
}
},
async (req, reply) => {
const userId = sessionUserId(req)
if (!userId) {
return reply.unauthorized()
}
// -> The site names the entry in the user's authenticator app, and is the one being browsed
// rather than one the client names: nothing else about this request is client-chosen either
const site = req.hostname
? await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname })
: null
try {
const { continuationToken, tfaQRImage, tfaSecret } =
await WIKI.models.users.startProfileTfaSetup({
userId,
strategyId: req.body.strategyId,
siteId: site?.id
})
return {
ok: true,
continuationToken,
tfaQRImage,
tfaSecret
}
} catch (err: any) {
rethrowAsBadRequest(err)
}
}
)
/**
* FINISH OWN 2FA SETUP
*/
app.put<{ Body: { strategyId: string; continuationToken: string; securityCode: string } }>(
'/profile/tfa',
{
schema: {
summary: 'Activate the 2FA secret the logged in user just set up',
description:
'Checks a code from the users authenticator against the secret generated by `POST /users/profile/tfa`, and activates it. A wrong code can be retried a handful of times before the continuation token is discarded and the setup has to be started again.',
tags: ['Users'],
body: {
type: 'object',
required: ['strategyId', 'continuationToken', 'securityCode'],
properties: {
strategyId: { type: 'string', format: 'uuid' },
continuationToken: { type: 'string', minLength: 1, maxLength: 255 },
securityCode: {
type: 'string',
pattern: '^[0-9]{6}$',
description: 'The six digits shown by the authenticator app.'
}
}
},
response: {
200: {
description: '2FA activated successfully',
type: 'object',
properties: {
ok: { type: 'boolean' },
message: { type: 'string' }
}
}
}
}
},
async (req, reply) => {
const userId = sessionUserId(req)
if (!userId) {
return reply.unauthorized()
}
try {
await WIKI.models.users.confirmTfaSetup({
userId,
strategyId: req.body.strategyId,
continuationToken: req.body.continuationToken,
securityCode: req.body.securityCode
})
} catch (err: any) {
rethrowAsBadRequest(err)
}
return {
ok: true,
message: '2FA enabled successfully.'
}
}
)
/**
* TURN OWN 2FA OFF
*/
app.delete<{ Params: { strategyId: string } }>(
'/profile/tfa/:strategyId',
{
schema: {
summary: "Turn 2FA off on the logged in user's account",
description:
'Forgets the secret, so setting 2FA up again starts from a new one. Refused when the account is flagged for 2FA or the strategy enforces it — the next login would only ask for it again.',
tags: ['Users'],
params: {
type: 'object',
properties: {
strategyId: { type: 'string', format: 'uuid' }
},
required: ['strategyId']
},
response: {
204: {
description: '2FA turned off successfully'
}
}
}
},
async (req, reply) => {
const userId = sessionUserId(req)
if (!userId) {
return reply.unauthorized()
}
try {
await WIKI.models.users.disableTfa(userId, req.params.strategyId)
} catch (err: any) {
rethrowAsBadRequest(err)
}
return reply.code(204).send()
}
)
/**
* START REGISTERING A PASSKEY
*/
app.post(
'/profile/passkeys/challenge',
{
schema: {
summary: 'Get the options for registering a new passkey',
description:
"Pass the result to the browser's WebAuthn API, then send what the authenticator produces to `POST /users/profile/passkeys`. The credential is bound to the hostname of this request, so a passkey registered on one site of a multi-site instance does not work on another.",
tags: ['Users'],
response: {
200: {
description: 'Registration options',
type: 'object',
properties: {
ok: { type: 'boolean' },
registrationOptions: {
type: 'object',
additionalProperties: true,
description: 'A WebAuthn `PublicKeyCredentialCreationOptions`, JSON-encoded.'
}
}
}
}
}
},
async (req, reply) => {
const userId = sessionUserId(req)
if (!userId) {
return reply.unauthorized()
}
try {
const { registrationOptions, pending } = await WIKI.models.passkeys.startRegistration({
userId,
hostname: req.hostname,
origin: req.headers.origin
})
// -> Kept out of the client's hands: what the authenticator signs is only worth anything if the
// challenge it answers is one this server remembers issuing
req.session.passkeyRegistration = pending
return {
ok: true,
registrationOptions
}
} catch (err: any) {
rethrowAsBadRequest(err)
}
}
)
/**
* FINISH REGISTERING A PASSKEY
*/
app.post<{ Body: { name: string; registrationResponse: Record<string, any> } }>(
'/profile/passkeys',
{
schema: {
summary: 'Register the passkey an authenticator just created',
tags: ['Users'],
body: {
type: 'object',
required: ['name', 'registrationResponse'],
properties: {
name: {
type: 'string',
minLength: 1,
maxLength: 255,
description: 'What to call it in the list, e.g. the device it lives on.'
},
registrationResponse: {
type: 'object',
additionalProperties: true,
description: "The browser's WebAuthn registration response, JSON-encoded."
}
}
},
response: {
200: {
description: 'Passkey registered successfully',
type: 'object',
properties: {
ok: { type: 'boolean' },
passkey: { $ref: 'Passkey#' }
}
}
}
}
},
async (req, reply) => {
const userId = sessionUserId(req)
if (!userId) {
return reply.unauthorized()
}
try {
const passkey = await WIKI.models.passkeys.finalizeRegistration({
userId,
name: req.body.name,
registrationResponse: req.body.registrationResponse as any,
pending: req.session.passkeyRegistration
})
return {
ok: true,
passkey
}
} catch (err: any) {
rethrowAsBadRequest(err)
} finally {
// -> Spent either way: a rejected response does not get a second go at the same challenge
req.session.passkeyRegistration = undefined
}
}
)
/**
* REMOVE A PASSKEY
*/
app.delete<{ Params: { passkeyId: string } }>(
'/profile/passkeys/:passkeyId',
{
schema: {
summary: 'Remove one of the logged in users passkeys',
description:
'Only this instance forgets it — the credential itself lives on the users device and has to be deleted there too.',
tags: ['Users'],
params: {
type: 'object',
properties: {
passkeyId: {
type: 'string',
description: 'The credential ID, as listed by `GET /users/profile/auth`.'
}
},
required: ['passkeyId']
},
response: {
204: {
description: 'Passkey removed successfully'
}
}
}
},
async (req, reply) => {
const userId = sessionUserId(req)
if (!userId) {
return reply.unauthorized()
}
if (!(await WIKI.models.passkeys.remove(userId, req.params.passkeyId))) {
return reply.notFound('You have no passkey with this ID.')
}
return reply.code(204).send()
}
)
/**
* GET USER DEFAULTS
*

@ -0,0 +1,13 @@
CREATE TABLE "approvalRules" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"match" varchar(16) DEFAULT 'START' NOT NULL,
"path" varchar(2048) DEFAULT '' NOT NULL,
"submitterGroups" jsonb DEFAULT '[]' NOT NULL,
"reviewerGroups" jsonb DEFAULT '[]' NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"siteId" uuid NOT NULL
);
--> statement-breakpoint
CREATE INDEX "approvalRules_siteId_idx" ON "approvalRules" ("siteId");--> statement-breakpoint
ALTER TABLE "approvalRules" ADD CONSTRAINT "approvalRules_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");

File diff suppressed because it is too large Load Diff

@ -0,0 +1,2 @@
ALTER TABLE "approvalRules" ADD COLUMN "name" varchar(255) DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "approvalRules" ADD COLUMN "isEnabled" boolean DEFAULT true NOT NULL;

File diff suppressed because it is too large Load Diff

@ -0,0 +1,21 @@
CREATE TABLE "pageEditSubmissions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"content" text NOT NULL,
"patch" text NOT NULL,
"baseHash" varchar(64) NOT NULL,
"guestName" varchar(255),
"guestEmail" varchar(255),
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"pageId" uuid NOT NULL,
"siteId" uuid NOT NULL,
"authorId" uuid
);
--> statement-breakpoint
CREATE INDEX "pageEditSubmissions_pageId_idx" ON "pageEditSubmissions" ("pageId");--> statement-breakpoint
CREATE INDEX "pageEditSubmissions_siteId_idx" ON "pageEditSubmissions" ("siteId");--> statement-breakpoint
CREATE INDEX "pageEditSubmissions_authorId_idx" ON "pageEditSubmissions" ("authorId");--> statement-breakpoint
CREATE UNIQUE INDEX "pageEditSubmissions_page_author_idx" ON "pageEditSubmissions" ("pageId","authorId") WHERE "authorId" IS NOT NULL;--> statement-breakpoint
ALTER TABLE "pageEditSubmissions" ADD CONSTRAINT "pageEditSubmissions_pageId_pages_id_fkey" FOREIGN KEY ("pageId") REFERENCES "pages"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "pageEditSubmissions" ADD CONSTRAINT "pageEditSubmissions_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");--> statement-breakpoint
ALTER TABLE "pageEditSubmissions" ADD CONSTRAINT "pageEditSubmissions_authorId_users_id_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id");

File diff suppressed because it is too large Load Diff

@ -51,6 +51,39 @@ export const apiKeys = pgTable('apiKeys', {
updatedAt: timestamp().notNull().defaultNow()
})
// APPROVAL RULES ----------------------
/**
* Which pages accept edit suggestions, who may submit them, and who reviews them.
*
* Per site, and matched the way group page rules are: a mode plus a pattern. A page no rule matches
* accepts no suggestions at all, so this table being empty means the feature is off.
*/
export const approvalRules = pgTable(
'approvalRules',
{
id: uuid().primaryKey().defaultRandom(),
name: varchar({ length: 255 }).notNull().default(''),
// -> A rule can be turned off without losing what it says, which is how an administrator suspends
// suggestions on a section without having to write the rule again afterwards.
isEnabled: boolean().notNull().default(true),
// -> One of START / EXACT / END / REGEX / TAG / TAGALL, the same set group page rules use. A
// varchar rather than an enum so that adding a mode does not need a migration; the API schema
// is what rejects an unknown one.
match: varchar({ length: 16 }).notNull().default('START'),
path: varchar({ length: 2048 }).notNull().default(''),
// -> Group IDs. Resolved on use rather than joined, so deleting a group takes effect at once, the
// way `apiKeys.groups` works.
submitterGroups: jsonb().notNull().default([]),
reviewerGroups: jsonb().notNull().default([]),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
siteId: uuid()
.notNull()
.references(() => sites.id)
},
(table) => [index('approvalRules_siteId_idx').on(table.siteId)]
)
// ASSETS ------------------------------
export const assetKindEnum = pgEnum('assetKind', ['document', 'image', 'other'])
export const assets = pgTable(
@ -342,6 +375,51 @@ export const pages = pgTable(
]
)
// PAGE EDIT SUBMISSIONS ---------------
/**
* An edit suggested by somebody who may read a page but not change it, waiting to be reviewed.
*
* Both the resulting source and a patch are kept, because they answer different questions. The patch
* is what a reviewer merges it is computed against the page as it stood at submission time, so two
* people suggesting edits to different parts of a page can both be accepted. The source is what the
* author resumes from and what a review screen shows, and it cannot be reconstructed from the patch
* alone once the page has moved on.
*/
export const pageEditSubmissions = pgTable(
'pageEditSubmissions',
{
id: uuid().primaryKey().defaultRandom(),
content: text().notNull(),
/** Unified diff, from the page content this was based on to `content`. */
patch: text().notNull(),
/** SHA-256 of that base content, so a reviewer can tell the page has changed underneath. */
baseHash: varchar({ length: 64 }).notNull(),
// -> A guest has no account to attribute the suggestion to, so it says who sent it. Null for a
// logged in author, whose name is on `authorId` instead.
guestName: varchar({ length: 255 }),
guestEmail: varchar({ length: 255 }),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
pageId: uuid()
.notNull()
.references(() => pages.id, { onDelete: 'cascade' }),
siteId: uuid()
.notNull()
.references(() => sites.id),
authorId: uuid().references(() => users.id)
},
(table) => [
index('pageEditSubmissions_pageId_idx').on(table.pageId),
index('pageEditSubmissions_siteId_idx').on(table.siteId),
index('pageEditSubmissions_authorId_idx').on(table.authorId),
// -> One open suggestion per person per page: coming back to the button continues that one rather
// than starting a second. Guests are excluded because they are all the same nobody.
uniqueIndex('pageEditSubmissions_page_author_idx')
.on(table.pageId, table.authorId)
.where(sql`"authorId" IS NOT NULL`)
]
)
// SETTINGS ----------------------------
export const settings = pgTable('settings', {
key: varchar({ length: 255 }).notNull().primaryKey(),

@ -225,3 +225,17 @@ export class CustomError extends Error {
this.statusCode = statusCode
}
}
/**
* Rethrow a failure raised by the authentication models as an HTTP error.
*
* Those models signal a rejected request by throwing an `ERR_*` code rather than prose, because the
* client has a translation for each one so the code travels to the client as the message of a 400.
* Anything else is an actual fault and is left alone, for the error handler to log and answer 500 to.
*/
export function rethrowAsBadRequest(err: any): never {
if (typeof err?.message === 'string' && err.message.startsWith('ERR_')) {
throw new CustomError('Bad Request', err.message)
}
throw err
}

@ -0,0 +1,168 @@
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
/**
* Time-based one-time passwords (RFC 6238), as every authenticator app implements them: HMAC-SHA1
* over a 30-second counter, truncated to 6 digits, keyed by a base32 secret.
*
* Written here rather than pulled from a package because that is the whole of it the algorithm is
* a dozen lines, and the base32 codec it needs is another twenty. The parameters below are not
* configurable on purpose: they are what an `otpauth://` URI means when it omits them, and an
* authenticator app that reads a QR code has no way to be told anything else.
*/
/** Digits in a generated code. */
const codeDigits = 6
/** Seconds each code is valid for, before drift is taken into account. */
const periodSeconds = 30
/**
* How many periods either side of the current one are accepted, i.e. a code stays usable for ±30s
* around its own window. Clocks drift, and a user typing six digits routinely crosses a boundary.
*/
const allowedDrift = 1
/**
* Bytes of entropy in a generated secret. 20 bytes is the SHA-1 block size and encodes to exactly 32
* base32 characters with no padding, which is what authenticator apps expect to be handed.
*/
const secretBytes = 20
const base32Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
/**
* Encode bytes as unpadded base32 (RFC 4648), the encoding `otpauth://` URIs use for secrets.
*/
function base32Encode(bytes: Buffer): string {
let out = ''
let bits = 0
let value = 0
for (const byte of bytes) {
value = (value << 8) | byte
bits += 8
while (bits >= 5) {
out += base32Alphabet[(value >>> (bits - 5)) & 31]
bits -= 5
}
}
// -> A trailing group of fewer than 5 bits still carries data; pad it with zeroes on the right
if (bits > 0) {
out += base32Alphabet[(value << (5 - bits)) & 31]
}
return out
}
/**
* Decode an unpadded or padded base32 string. Case-insensitive, and separators a user may have typed
* are ignored the secret is also displayed for manual entry, not only scanned.
*
* @throws If the value contains a character that is not base32
*/
function base32Decode(value: string): Buffer {
const normalized = value.toUpperCase().replaceAll(/[\s-]/g, '').replaceAll('=', '')
const bytes: number[] = []
let bits = 0
let acc = 0
for (const char of normalized) {
const index = base32Alphabet.indexOf(char)
if (index < 0) {
throw new Error(`Not a base32 character: ${char}`)
}
acc = (acc << 5) | index
bits += 5
if (bits >= 8) {
bytes.push((acc >>> (bits - 8)) & 255)
bits -= 8
}
}
return Buffer.from(bytes)
}
/**
* The code a given secret produces for a given counter value.
*/
function codeAt(secret: Buffer, counter: number): string {
const counterBytes = Buffer.alloc(8)
counterBytes.writeBigUInt64BE(BigInt(counter))
const digest = createHmac('sha1', secret).update(counterBytes).digest()
// -> Dynamic truncation: the low nibble of the last byte picks where in the digest to read from
const offset = digest[digest.length - 1]! & 0x0f
const binary = digest.readUInt32BE(offset) & 0x7fffffff
return String(binary % 10 ** codeDigits).padStart(codeDigits, '0')
}
/**
* A fresh TOTP secret, base32-encoded.
*/
export function generateTotpSecret(): string {
return base32Encode(randomBytes(secretBytes))
}
/**
* The `otpauth://` URI an authenticator app reads from the QR code.
*
* The label is `issuer:account` and the issuer is repeated as a parameter, which is what apps
* actually key their entries on. Both are URI-encoded; a wiki title containing a `:` or a `?` would
* otherwise produce a URI that parses as something else.
*
* @param secret Base32 secret, as returned by `generateTotpSecret()`
* @param account Who the code belongs to, i.e. the user's email
* @param issuer What it logs into, i.e. the site title
*/
export function buildTotpUri({
secret,
account,
issuer
}: {
secret: string
account: string
issuer: string
}): string {
const label = encodeURIComponent(`${issuer}:${account}`)
const params = new URLSearchParams({
secret,
issuer,
algorithm: 'SHA1',
digits: String(codeDigits),
period: String(periodSeconds)
})
return `otpauth://totp/${label}?${params.toString()}`
}
/**
* Whether a code is one the secret currently produces, allowing for clock drift.
*
* Compared byte-wise in constant time. That matters less here than for a password a wrong code is
* one of a million and expires in seconds but the comparison is free to get right.
*
* @param secret Base32 secret stored for the user
* @param code The six digits the user typed
* @returns False for anything that is not six digits, or for a secret that will not decode
*/
export function verifyTotpCode(secret: string, code: string): boolean {
if (!secret || !/^[0-9]{6}$/.test(code)) {
return false
}
let secretKey: Buffer
try {
secretKey = base32Decode(secret)
} catch {
return false
}
if (secretKey.length < 1) {
return false
}
const expected = Buffer.from(code, 'utf8')
const counter = Math.floor(Date.now() / 1000 / periodSeconds)
let matched = false
for (let drift = -allowedDrift; drift <= allowedDrift; drift++) {
// -> Every candidate is compared, rather than returning on the first hit, so that the work done
// does not depend on which window the code came from
if (timingSafeEqual(Buffer.from(codeAt(secretKey, counter + drift), 'utf8'), expected)) {
matched = true
}
}
return matched
}

@ -72,7 +72,47 @@
"admin.api.toggleStateDisabledSuccess": "API has been disabled successfully.",
"admin.api.toggleStateEnabledSuccess": "API has been enabled successfully.",
"admin.api.toggleStateFailed": "Failed to switch the API state.",
"admin.approval.createSuccess": "Rule created successfully.",
"admin.approval.deleteFailed": "Failed to delete the rule.",
"admin.approval.deleteRule": "Delete Rule",
"admin.approval.deleteRuleConfirm": "Are you sure you want to delete the rule for {pattern}? Pages it covers will stop accepting edit suggestions, unless another rule also matches them.",
"admin.approval.deleteSuccess": "Rule deleted successfully.",
"admin.approval.disableSuccess": "Rule disabled. Pages it covers no longer accept edit suggestions.",
"admin.approval.editRule": "Edit Rule",
"admin.approval.enableSuccess": "Rule enabled.",
"admin.approval.enabled": "Enabled",
"admin.approval.formInvalid": "One or more fields are invalid.",
"admin.approval.loadFailed": "Failed to load the approval rules.",
"admin.approval.match": "Applies To",
"admin.approval.matchEnd": "Path ends with",
"admin.approval.matchExact": "Path is exactly",
"admin.approval.matchHint": "How pages are matched against the pattern below.",
"admin.approval.matchRegex": "Path matches regex",
"admin.approval.matchStart": "Path starts with",
"admin.approval.matchTag": "Page has any of the tags",
"admin.approval.matchTagAll": "Page has all of the tags",
"admin.approval.name": "Rule Name",
"admin.approval.nameHint": "How this rule is identified in the list, e.g. Documentation suggestions",
"admin.approval.nameRequired": "A rule name is required.",
"admin.approval.newRule": "New Rule",
"admin.approval.noRules": "No approval rules yet. Pages accept no edit suggestions until a rule covers them.",
"admin.approval.path": "Path",
"admin.approval.pathHint": "Without the leading slash, e.g. docs/getting-started",
"admin.approval.pathInvalidRegex": "Not a valid regular expression: {reason}",
"admin.approval.pathRequired": "A path is required.",
"admin.approval.reviewers": "Reviews submissions",
"admin.approval.reviewersHint": "Members of these groups review submissions, and are notified when a new one comes in.",
"admin.approval.reviewersRequired": "Select at least one group to review submissions.",
"admin.approval.saveFailed": "Failed to save the rule.",
"admin.approval.submitters": "Can submit edits",
"admin.approval.submittersHint": "Members of these groups can submit edit suggestions for matching pages.",
"admin.approval.submittersRequired": "Select at least one group that can submit edits.",
"admin.approval.subtitle": "Define which pages accept edit suggestions, and who reviews them",
"admin.approval.tags": "Tags",
"admin.approval.tagsHint": "Comma-separated list of tags.",
"admin.approval.tagsRequired": "At least one tag is required.",
"admin.approval.title": "Approvals",
"admin.approval.updateSuccess": "Rule updated successfully.",
"admin.audit.title": "Audit Log",
"admin.auth.activeStrategies": "Active Strategies",
"admin.auth.addFailed": "Failed to add the strategy.",
@ -1325,7 +1365,10 @@
"auth.tfa.verifyToken": "Verify",
"auth.tfaFormTitle": "Enter the security code generated from your trusted device:",
"auth.tfaSetupInstrFirst": "Scan the QR code below from your mobile 2FA application:",
"auth.tfaSetupInstrManual": "Or enter this setup key manually:",
"auth.tfaSetupInstrSecond": "Enter the security code generated from your trusted device:",
"auth.tfaSetupKeyCopied": "Setup key copied to the clipboard.",
"auth.tfaSetupKeyCopyFailed": "Could not copy the setup key to the clipboard.",
"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...",
@ -1338,6 +1381,7 @@
"common.actions.close": "Close",
"common.actions.commit": "Commit",
"common.actions.confirm": "Confirm",
"common.actions.continueSuggestion": "Continue Suggestion",
"common.actions.copy": "Copy",
"common.actions.copyURL": "Copy URL",
"common.actions.create": "Create",
@ -1377,6 +1421,9 @@
"common.actions.saveAndClose": "Save and Close",
"common.actions.saveChanges": "Save Changes",
"common.actions.select": "Select",
"common.actions.submitEdits": "Submit Edits",
"common.actions.suggestEdits": "Suggest Edits",
"common.actions.suggestedEdit": "Suggested Edit",
"common.actions.update": "Update",
"common.actions.upload": "Upload",
"common.actions.view": "View",
@ -1509,6 +1556,17 @@
"common.page.ratePage": "Rate this page",
"common.page.returnNormalView": "Return to Normal View",
"common.page.share": "Share",
"common.page.suggestDiscarded": "Your suggested edits have been discarded.",
"common.page.suggestEmail": "Your Email Address",
"common.page.suggestEmailHint": "Only used to contact you about this suggestion.",
"common.page.suggestFailed": "Could not open the page for suggestions.",
"common.page.suggestIdentifyHint": "You are not logged in, so please tell us who to credit these edits to and how a reviewer can reach you.",
"common.page.suggestIdentifyTitle": "Submit Suggested Edits",
"common.page.suggestName": "Your Name",
"common.page.suggestSubmitFailed": "Failed to submit your suggested edits.",
"common.page.suggestSubmitted": "Your suggested edits have been submitted.",
"common.page.suggestSubmittedHint": "They are pending review. You will be able to keep editing them until a reviewer accepts or declines them.",
"common.page.suggestSubmittedHintGuest": "They are pending review by this site's editors.",
"common.page.tags": "Tags",
"common.page.tagsMatching": "Pages matching tags",
"common.page.toc": "Table of Contents",
@ -1800,9 +1858,32 @@
"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_CHANGE_PASSWORD_FAILED": "The password could not be changed.",
"error.ERR_EXPIRED_VALIDATION_TOKEN": "This request has expired. Please start over.",
"error.ERR_INACTIVE_USER": "This account is deactivated.",
"error.ERR_INCORRECT_CURRENT_PASSWORD": "The current password is incorrect.",
"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_FAILED": "The email or password is invalid.",
"error.ERR_LOGIN_RESTRICTED": "Password login is turned off for this account.",
"error.ERR_NO_OTHER_LOGIN_METHOD": "Password login cannot be turned off: it is the only way to login to this account.",
"error.ERR_PASSKEY_NOT_SETUP": "No passkey registration is in progress. Please start over.",
"error.ERR_PASSWORD_LOGIN_NOT_APPLICABLE": "This authentication method does not use a password stored here.",
"error.ERR_PASSWORD_TOO_SHORT": "The password must be at least 8 characters long.",
"error.ERR_PK_ALREADY_REGISTERED": "It looks like this authenticator is already registered.",
"error.ERR_PK_HOSTNAME_MISSING": "Your administrator must set a valid site hostname before passkeys can be used.",
"error.ERR_PK_INSECURE_ORIGIN": "Passkeys require a secure (HTTPS) connection to this site.",
"error.ERR_PK_NAME_MISSING_OR_INVALID": "Passkey name is missing or invalid.",
"error.ERR_PK_USER_CANCELLED": "Passkey registration aborted. Make sure to remove the key from your device.",
"error.ERR_PK_VERIFICATION_FAILED": "This passkey could not be verified.",
"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.",
"error.ERR_TFA_FAILED": "The security code could not be verified.",
"error.ERR_TFA_INCORRECT_TOKEN": "This security code is incorrect.",
"error.ERR_TFA_INVALID_REQUEST": "Missing or incomplete security code.",
"error.ERR_TFA_NOT_ACTIVE": "2FA is not enabled on this account.",
"error.ERR_USER_NOT_VERIFIED": "This account has not been verified yet.",
"fileman.7zFileType": "7zip Archive",
"fileman.aacFileType": "AAC Audio File",
"fileman.aiFileType": "Adobe Illustrator Document",
@ -1977,16 +2058,28 @@
"profile.appearanceHint": "Use the light or dark theme.",
"profile.appearanceLight": "Light",
"profile.auth": "Authentication",
"profile.authActions": "Authentication options",
"profile.authChangePassword": "Change Password",
"profile.authDisablePasswordLogin": "Turn Off Password Login",
"profile.authDisablePasswordLoginConfirm": "Your password will no longer sign you in. Make sure you can login with a passkey or another authentication method first — otherwise only an administrator can restore access.",
"profile.authDisablePasswordLoginFailed": "Failed to turn off password login.",
"profile.authDisablePasswordLoginSuccess": "Password login turned off successfully.",
"profile.authDisableTfa": "Turn Off 2FA",
"profile.authDisableTfaConfirm": "Are you sure you want to disable Two Factor Authentication?",
"profile.authDisableTfaFailed": "Failed to turn off 2FA.",
"profile.authDisableTfaSuccess": "2FA turned off successfully.",
"profile.authEnablePasswordLogin": "Turn On Password Login",
"profile.authEnablePasswordLoginFailed": "Failed to turn on password login.",
"profile.authEnablePasswordLoginSuccess": "Password login turned on successfully.",
"profile.authInfo": "Your account is associated with the following authentication methods:",
"profile.authLoadingFailed": "Failed to load authentication methods.",
"profile.authModifyTfa": "Modify 2FA",
"profile.authPasswordLoginOff": "Password login is turned off for this account.",
"profile.authPasswordLoginOnlyMethod": "Register a passkey or link another authentication method before turning this off.",
"profile.authSetTfa": "Set 2FA",
"profile.authSetTfaLoading": "Setting up 2FA... Please wait",
"profile.authTfaActive": "Two-factor authentication is enabled on this account.",
"profile.authTfaBadge": "2FA",
"profile.avatar": "Avatar",
"profile.avatarClearFailed": "Failed to clear profile picture.",
"profile.avatarClearSuccess": "Profile picture cleared successfully.",

@ -0,0 +1,384 @@
import { createHash } from 'node:crypto'
import { createPatch } from 'diff'
import { and, asc, eq, inArray, sql } from 'drizzle-orm'
import {
approvalRules as approvalRulesTable,
groups as groupsTable,
pageEditSubmissions as submissionsTable
} from '../db/schema.ts'
/**
* How a rule decides which pages it covers. The same set group page rules use, so an administrator
* writing one has learnt the other.
*/
export const approvalMatchModes = ['START', 'EXACT', 'END', 'REGEX', 'TAG', 'TAGALL'] as const
export type ApprovalMatchMode = (typeof approvalMatchModes)[number]
/** The part of a page a rule is matched against. */
export interface ApprovalPageRef {
id: string
path: string
tags: string[]
}
/** An edit suggested against a page, as the author's own view of it. */
export interface PageEditSubmission {
id: string
content: string
baseHash: string
createdAt: Date
updatedAt: Date
}
/** An approval rule as the API exposes it. */
export interface ApprovalRule {
id: string
name: string
isEnabled: boolean
match: ApprovalMatchMode
path: string
/** IDs of the groups whose members may submit edit suggestions for a matching page. */
submitterGroups: string[]
/** IDs of the groups that review those submissions, and are notified of new ones. */
reviewerGroups: string[]
createdAt: Date
updatedAt: Date
}
/** The fields a rule is created or updated with. */
export interface ApprovalRulePatch {
name?: string
isEnabled?: boolean
match?: ApprovalMatchMode
path?: string
submitterGroups?: string[]
reviewerGroups?: string[]
}
/**
* The tags of a tag-mode rule, as they are written into the one pattern field: comma-separated, and
* compared in lower case the way page tags are stored.
*/
function parseTags(value: string): string[] {
return value
.split(',')
.map((tag) => tag.trim().toLowerCase())
.filter((tag) => tag.length > 0)
}
const ruleSelection = {
id: approvalRulesTable.id,
name: approvalRulesTable.name,
isEnabled: approvalRulesTable.isEnabled,
match: approvalRulesTable.match,
path: approvalRulesTable.path,
submitterGroups: approvalRulesTable.submitterGroups,
reviewerGroups: approvalRulesTable.reviewerGroups,
createdAt: approvalRulesTable.createdAt,
updatedAt: approvalRulesTable.updatedAt
}
/**
* Approvals model
*
* Only the rules for now: which pages accept edit suggestions, from whom, and who reviews them. The
* submissions themselves are a separate concern and are not stored yet.
*/
class Approvals {
/**
* Every rule configured for a site, by name.
*
* Order carries no meaning a page is covered if any enabled rule matches it so the list is
* sorted for the reader: alphabetically, ignoring case, since `Zoo` sorting before `apple` is not
* what alphabetical means to anyone. Two rules sharing a name keep a stable order by age.
*/
async getRules(siteId: string): Promise<ApprovalRule[]> {
return WIKI.db
.select(ruleSelection)
.from(approvalRulesTable)
.where(eq(approvalRulesTable.siteId, siteId))
.orderBy(
asc(sql`lower(${approvalRulesTable.name})`),
asc(approvalRulesTable.createdAt)
) as Promise<ApprovalRule[]>
}
/**
* A single rule, scoped to its site so that an ID from another site cannot be reached through it.
*
* @returns The rule, or null if this site has no such rule
*/
async getRule(siteId: string, id: string): Promise<ApprovalRule | null> {
const rows = await WIKI.db
.select(ruleSelection)
.from(approvalRulesTable)
.where(and(eq(approvalRulesTable.siteId, siteId), eq(approvalRulesTable.id, id)))
.limit(1)
return (rows[0] as ApprovalRule) ?? null
}
/**
* The IDs among those given that are not groups on this instance.
*
* A picker only offers real groups, so a miss means a stale client or a group deleted mid-edit
* worth reporting rather than storing an ID that resolves to nobody.
*/
async getUnknownGroupIds(groupIds: string[]): Promise<string[]> {
const wanted = [...new Set(groupIds)]
if (wanted.length < 1) {
return []
}
const found = await WIKI.db
.select({ id: groupsTable.id })
.from(groupsTable)
.where(inArray(groupsTable.id, wanted))
const foundIds = new Set(found.map((g: any) => g.id))
return wanted.filter((id) => !foundIds.has(id))
}
/**
* Create a rule for a site.
*
* @returns The rule as stored
*/
async createRule(siteId: string, patch: ApprovalRulePatch): Promise<ApprovalRule> {
const rows = await WIKI.db
.insert(approvalRulesTable)
.values({
siteId,
name: patch.name ?? '',
isEnabled: patch.isEnabled ?? true,
match: patch.match ?? 'START',
path: patch.path ?? '',
submitterGroups: patch.submitterGroups ?? [],
reviewerGroups: patch.reviewerGroups ?? []
})
.returning(ruleSelection)
return rows[0] as ApprovalRule
}
/**
* Update a rule, leaving out fields alone.
*
* @returns The updated rule, or null if this site has no such rule
*/
async updateRule(
siteId: string,
id: string,
patch: ApprovalRulePatch
): Promise<ApprovalRule | null> {
const values: Record<string, any> = { updatedAt: new Date() }
for (const key of [
'name',
'isEnabled',
'match',
'path',
'submitterGroups',
'reviewerGroups'
] as const) {
if (patch[key] !== undefined) {
values[key] = patch[key]
}
}
const rows = await WIKI.db
.update(approvalRulesTable)
.set(values)
.where(and(eq(approvalRulesTable.siteId, siteId), eq(approvalRulesTable.id, id)))
.returning(ruleSelection)
return (rows[0] as ApprovalRule) ?? null
}
/**
* Whether a rule covers a page.
*
* Paths are compared without a leading slash on either side, which is how they are stored and how
* the rule is written. A regular expression that will not compile matches nothing rather than
* throwing: the rule is already refused at the API, so this is only reached by one that was valid
* when it was written and stopped being so.
*/
matchesPage(rule: ApprovalRule, page: ApprovalPageRef): boolean {
const pagePath = page.path.replace(/^\/+/, '')
const rulePath = rule.path.replace(/^\/+/, '')
switch (rule.match) {
case 'START':
return pagePath.startsWith(rulePath)
case 'EXACT':
return pagePath === rulePath
case 'END':
return pagePath.endsWith(rulePath)
case 'REGEX':
try {
return new RegExp(rulePath).test(pagePath)
} catch {
return false
}
case 'TAG':
return parseTags(rule.path).some((tag) => page.tags.includes(tag))
case 'TAGALL': {
const wanted = parseTags(rule.path)
return wanted.length > 0 && wanted.every((tag) => page.tags.includes(tag))
}
default:
return false
}
}
/**
* The groups an actor belongs to, as the rules see them.
*
* A request with no session is not nobody: it is the guests group, and a rule naming that group is
* how an administrator opens suggestions to anyone reading the site. Taken from the fixed ID in the
* configuration rather than by reading the guest account's membership — that account's groups cannot
* be changed, and the ID of the account itself only exists while an instance is being seeded.
*/
getActorGroupIds(req: any): string[] {
if (req.session?.authenticated && req.session.user?.id) {
return req.session.groups ?? []
}
return [WIKI.data.systemIds.guestsGroupId]
}
/**
* The enabled rule that lets these groups suggest an edit to this page, if there is one.
*
* @returns The first matching rule, or null when the page takes no suggestions from them
*/
async findSubmitRule(
siteId: string,
page: ApprovalPageRef,
groupIds: string[]
): Promise<ApprovalRule | null> {
if (groupIds.length < 1) {
return null
}
const rules = await this.getRules(siteId)
return (
rules.find(
(rule) =>
rule.isEnabled &&
rule.submitterGroups.some((id) => groupIds.includes(id)) &&
this.matchesPage(rule, page)
) ?? null
)
}
/**
* The suggestion this user already has open on this page, if any.
*
* Guests get null whoever they are: there is no account to look one up by, so every guest
* suggestion is a new one.
*/
async getOwnSubmission(
pageId: string,
authorId: string | null
): Promise<PageEditSubmission | null> {
if (!authorId) {
return null
}
const rows = await WIKI.db
.select({
id: submissionsTable.id,
content: submissionsTable.content,
baseHash: submissionsTable.baseHash,
createdAt: submissionsTable.createdAt,
updatedAt: submissionsTable.updatedAt
})
.from(submissionsTable)
.where(and(eq(submissionsTable.pageId, pageId), eq(submissionsTable.authorId, authorId)))
.limit(1)
return (rows[0] as PageEditSubmission) ?? null
}
/**
* Store an edit somebody has suggested for a page.
*
* The patch is taken against the page as it stands right now, which is what makes two suggestions to
* different parts of the same page both applicable later. A logged in author has one open suggestion
* per page and this replaces it; a guest has no identity to match on, so each submission is its own.
*
* @param baseContent The page source the suggestion was made against
* @returns The stored suggestion
*/
async saveSubmission({
siteId,
page,
baseContent,
content,
authorId,
guestName,
guestEmail
}: {
siteId: string
page: ApprovalPageRef
baseContent: string
content: string
authorId: string | null
guestName?: string
guestEmail?: string
}): Promise<PageEditSubmission> {
const values = {
siteId,
pageId: page.id,
authorId,
content,
patch: createPatch(page.path, baseContent, content),
baseHash: createHash('sha256').update(baseContent).digest('hex'),
guestName: authorId ? null : (guestName ?? ''),
guestEmail: authorId ? null : (guestEmail ?? ''),
updatedAt: new Date()
}
const rows = authorId
? await WIKI.db
.insert(submissionsTable)
.values(values)
.onConflictDoUpdate({
target: [submissionsTable.pageId, submissionsTable.authorId],
// -> Matches the partial index, which only covers rows with an author
targetWhere: sql`"authorId" IS NOT NULL`,
set: {
content: values.content,
patch: values.patch,
baseHash: values.baseHash,
updatedAt: values.updatedAt
}
})
.returning()
: await WIKI.db.insert(submissionsTable).values(values).returning()
const stored = rows[0]
WIKI.logger.debug(
`Stored an edit suggestion for page ${page.id} from ${authorId ?? `guest <${guestEmail}>`}`
)
return {
id: stored.id,
content: stored.content,
baseHash: stored.baseHash,
createdAt: stored.createdAt,
updatedAt: stored.updatedAt
}
}
/**
* How many suggestions are waiting on a page. Counted for every reviewer, whoever wrote them.
*/
async countSubmissions(pageId: string): Promise<number> {
return WIKI.db.$count(submissionsTable, eq(submissionsTable.pageId, pageId))
}
/**
* Delete a rule.
*
* @returns Whether a rule was deleted
*/
async deleteRule(siteId: string, id: string): Promise<boolean> {
const result = await WIKI.db
.delete(approvalRulesTable)
.where(and(eq(approvalRulesTable.siteId, siteId), eq(approvalRulesTable.id, id)))
return (result.rowCount ?? 0) > 0
}
}
export const approvals = new Approvals()

@ -1,4 +1,5 @@
import { apiKeys } from './apiKeys.ts'
import { approvals } from './approvals.ts'
import { assets } from './assets.ts'
import { authentication } from './authentication.ts'
import { blocks } from './blocks.ts'
@ -11,6 +12,7 @@ import { jobs } from './jobs.ts'
import { locales } from './locales.ts'
import { navigation } from './navigation.ts'
import { pages } from './pages.ts'
import { passkeys } from './passkeys.ts'
import { rendering } from './rendering.ts'
import { search } from './search.ts'
import { security } from './security.ts'
@ -24,6 +26,7 @@ import { users } from './users.ts'
export default {
apiKeys,
approvals,
assets,
authentication,
blocks,
@ -36,6 +39,7 @@ export default {
locales,
navigation,
pages,
passkeys,
rendering,
search,
security,

@ -0,0 +1,467 @@
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse
} from '@simplewebauthn/server'
import { isoBase64URL } from '@simplewebauthn/server/helpers'
import { eq, sql } from 'drizzle-orm'
import { users as usersTable } from '../db/schema.ts'
import type {
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON
} from '@simplewebauthn/server'
import type { AfterLoginResult } from './users.ts'
/**
* One registered authenticator, as stored in the user's `passkeys` blob. Every binary value is held
* base64url-encoded, since this lives in a JSONB column.
*/
interface StoredPasskey {
/** The credential ID, which is also what the browser sends back to identify it. */
id: string
name: string
/** COSE public key, base64url-encoded. */
publicKey: string
/** Signature counter last reported by the authenticator, for replay detection. */
counter: number
transports?: AuthenticatorTransportFuture[]
createdAt: string
siteId: string
/** The hostname the credential is bound to. A passkey only works on the site it was created on. */
rpId: string
}
/**
* A ceremony waiting to be answered. Held on the session between the two requests a ceremony takes
* see the note on `Session.passkeyLogin` in `types/fastify.d.ts` for why it cannot live anywhere else.
*/
export interface PasskeyChallenge {
challenge: string
rpId: string
origin: string
siteId: string
}
/** What a user's `passkeys` column holds: the credentials themselves, and nothing transient. */
interface PasskeyStore {
authenticators?: StoredPasskey[]
}
/** A passkey as the profile page lists it — never the key material. */
export interface PasskeyInfo {
id: string
name: string
siteHostname: string
createdAt: string
}
/**
* Hostnames a browser treats as a secure context without TLS, so that `http://localhost:3001` the
* dev server is a usable origin. Anything else has to be https, which is a WebAuthn requirement
* rather than a choice made here.
*/
const insecureOriginExceptions = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])
/**
* The origin a passkey ceremony must be performed on.
*
* Taken from the request's own `Origin` header rather than assembled from the hostname, because the
* port is part of an origin and this instance does not know which one the browser reached it on. That
* is safe because the header is only trusted as far as it agrees with the host the request was
* addressed to: a page on another origin posting here would disagree, and is rejected. What the
* header cannot establish is that the connection was secure, so that is checked separately.
*
* @param origin The `Origin` header, if the client sent one
* @param hostname The host the request was addressed to, i.e. the RP ID
* @throws `ERR_PK_INSECURE_ORIGIN` for an origin that does not match, or that is not a secure context
*/
function resolveOrigin(origin: string | undefined, hostname: string): string {
// -> A client that sends no Origin at all is not a browser doing a WebAuthn ceremony, but it may
// still be a legitimate API client driving one, so the canonical https origin is assumed
if (!origin) {
return `https://${hostname}`
}
let parsed: URL
try {
parsed = new URL(origin)
} catch {
throw new Error('ERR_PK_INSECURE_ORIGIN')
}
if (parsed.hostname !== hostname) {
throw new Error('ERR_PK_INSECURE_ORIGIN')
}
if (parsed.protocol !== 'https:' && !insecureOriginExceptions.has(parsed.hostname)) {
throw new Error('ERR_PK_INSECURE_ORIGIN')
}
return parsed.origin
}
/**
* Passkeys (WebAuthn) model
*
* Credentials are stored in the user's `passkeys` JSONB column rather than a table of their own: they
* are only ever read for one user at a time, and they die with the account.
*/
class Passkeys {
/**
* The passkeys registered by a user, as the profile page lists them.
*/
async list(userId: string): Promise<PasskeyInfo[]> {
const store = await this.getStore(userId)
return (store.authenticators ?? []).map((pk) => ({
id: pk.id,
name: pk.name,
// -> The hostname it was registered against, not the site's current one: that is what the
// credential is actually bound to, and renaming a site does not move it
siteHostname: pk.rpId,
createdAt: pk.createdAt
}))
}
/**
* Options for registering a new passkey.
*
* @param userId The user registering it, who must be logged in
* @param hostname The host being browsed, which becomes the RP ID the credential is bound to
* @param origin The request's `Origin` header
* @returns The options to hand the browser, and the challenge to remember for
* `finalizeRegistration()`
* @throws `ERR_INVALID_USER`, `ERR_PK_HOSTNAME_MISSING` or `ERR_PK_INSECURE_ORIGIN`
*/
async startRegistration({
userId,
hostname,
origin
}: {
userId: string
hostname: string
origin?: string
}): Promise<{
registrationOptions: PublicKeyCredentialCreationOptionsJSON
pending: PasskeyChallenge
}> {
const user = await WIKI.models.users.getById(userId)
if (!user) {
throw new Error('ERR_INVALID_USER')
}
if (!hostname || hostname === '*') {
throw new Error('ERR_PK_HOSTNAME_MISSING')
}
const expectedOrigin = resolveOrigin(origin, hostname)
const site = await WIKI.models.sites.getSiteByHostname({ hostname })
const store = (user.passkeys ?? {}) as PasskeyStore
const options = await generateRegistrationOptions({
rpName: site?.config?.title || 'Wiki',
rpID: hostname,
// -> The user handle comes back on login as the only clue to who is signing in, so it is the
// user ID itself rather than a random value that would need a second lookup table
userID: new TextEncoder().encode(user.id),
userName: user.email,
userDisplayName: user.name,
attestationType: 'none',
authenticatorSelection: {
residentKey: 'required',
userVerification: 'preferred'
},
// -> Every credential the user already has, so the authenticator can refuse to enroll twice and
// the browser can say so before anything is stored
excludeCredentials: (store.authenticators ?? []).map((pk) => ({
id: pk.id,
transports: pk.transports
}))
})
return {
registrationOptions: options,
pending: {
challenge: options.challenge,
rpId: hostname,
origin: expectedOrigin,
siteId: site?.id ?? ''
}
}
}
/**
* Verify what the authenticator produced and store the credential under the given name.
*
* @param pending The challenge `startRegistration()` handed out, off the session
* @throws `ERR_INVALID_USER`, `ERR_PASSKEY_NOT_SETUP`, `ERR_PK_NAME_MISSING_OR_INVALID`,
* `ERR_PK_ALREADY_REGISTERED` or `ERR_PK_VERIFICATION_FAILED`
*/
async finalizeRegistration({
userId,
name,
registrationResponse,
pending
}: {
userId: string
name: string
registrationResponse: RegistrationResponseJSON
pending?: PasskeyChallenge
}): Promise<PasskeyInfo> {
const user = await WIKI.models.users.getById(userId)
if (!user) {
throw new Error('ERR_INVALID_USER')
}
if (!pending) {
throw new Error('ERR_PASSKEY_NOT_SETUP')
}
const store = (user.passkeys ?? {}) as PasskeyStore
const trimmedName = (name ?? '').trim()
if (trimmedName.length < 1 || trimmedName.length > 255) {
throw new Error('ERR_PK_NAME_MISSING_OR_INVALID')
}
let verification
try {
verification = await verifyRegistrationResponse({
response: registrationResponse,
expectedChallenge: pending.challenge,
expectedOrigin: pending.origin,
expectedRPID: pending.rpId,
// -> Matches the `preferred` asked for above: an authenticator that has no way to verify the
// user is still worth registering, and requiring it here would reject exactly those
requireUserVerification: false
})
} catch (err: any) {
WIKI.models.flags.authDebug(
`Passkey registration for user ${user.id} failed verification: ${err.message}`
)
throw new Error('ERR_PK_VERIFICATION_FAILED')
}
if (!verification.verified) {
throw new Error('ERR_PK_VERIFICATION_FAILED')
}
const { credential } = verification.registrationInfo
const authenticators = store.authenticators ?? []
if (authenticators.some((pk) => pk.id === credential.id)) {
throw new Error('ERR_PK_ALREADY_REGISTERED')
}
const passkey: StoredPasskey = {
id: credential.id,
name: trimmedName,
publicKey: isoBase64URL.fromBuffer(credential.publicKey),
counter: credential.counter,
transports: registrationResponse.response.transports,
createdAt: Temporal.Now.instant().toString({ smallestUnit: 'millisecond' }),
siteId: pending.siteId,
rpId: pending.rpId
}
await this.saveStore(user.id, { authenticators: [...authenticators, passkey] })
WIKI.models.flags.authDebug(
`User ${user.id} <${user.email}> registered passkey "${trimmedName}" on ${pending.rpId}`
)
return {
id: passkey.id,
name: passkey.name,
siteHostname: passkey.rpId,
createdAt: passkey.createdAt
}
}
/**
* Forget a passkey. The credential itself lives on the user's device and has to be removed there
* too, which is what the client says when this succeeds.
*
* @returns False if the user has no such passkey
*/
async remove(userId: string, passkeyId: string): Promise<boolean> {
const store = await this.getStore(userId)
const authenticators = store.authenticators ?? []
const remaining = authenticators.filter((pk) => pk.id !== passkeyId)
if (remaining.length === authenticators.length) {
return false
}
await this.saveStore(userId, { ...store, authenticators: remaining })
WIKI.models.flags.authDebug(`User ${userId} removed a passkey`)
return true
}
/**
* Options for logging in with a passkey.
*
* Nobody is named here, and no `allowCredentials` list is sent: every passkey is registered as a
* discoverable credential, so the authenticator offers whichever ones it holds for this host and the
* assertion says who signed. That is what makes a passkey login one gesture there is nothing to ask
* the user first, and no lookup that could reveal whether an address has an account.
*
* @returns The options to hand the browser, and the challenge to remember for `verifyLogin()`
* @throws `ERR_PK_HOSTNAME_MISSING` or `ERR_PK_INSECURE_ORIGIN`
*/
async startLogin({ hostname, origin }: { hostname: string; origin?: string }): Promise<{
authOptions: PublicKeyCredentialRequestOptionsJSON
pending: PasskeyChallenge
}> {
if (!hostname || hostname === '*') {
throw new Error('ERR_PK_HOSTNAME_MISSING')
}
const expectedOrigin = resolveOrigin(origin, hostname)
const options = await generateAuthenticationOptions({
rpID: hostname,
userVerification: 'preferred'
})
return {
authOptions: options,
pending: {
challenge: options.challenge,
rpId: hostname,
origin: expectedOrigin,
siteId: (await WIKI.models.sites.getSiteByHostname({ hostname }))?.id ?? ''
}
}
}
/**
* Verify a passkey login and, if it holds up, log the user in.
*
* A passkey establishes both who the user is and that they were present, so this does not go on to
* ask for a password or a 2FA code. The account checks the password strategy performs still apply
* a deactivated account cannot be signed into with a key either.
*
* Who signed comes out of the assertion's user handle, which is the only way this can work: the
* challenge was handed out before anyone was named.
*
* @param pending The challenge `startLogin()` handed out, off the session
* @returns The same shape a password login returns, so the client handles both the same way
* @throws `ERR_LOGIN_FAILED`, `ERR_INACTIVE_USER` or `ERR_USER_NOT_VERIFIED`
*/
async verifyLogin(
{
authResponse,
pending,
ip
}: {
authResponse: AuthenticationResponseJSON
pending?: PasskeyChallenge
ip?: string
},
req: any
): Promise<AfterLoginResult> {
if (!pending) {
WIKI.models.flags.authDebug(
'Passkey login rejected: no challenge outstanding on this session'
)
throw new Error('ERR_LOGIN_FAILED')
}
const userHandle = authResponse.response?.userHandle
if (!userHandle) {
WIKI.models.flags.authDebug('Passkey login rejected: the response carried no user handle')
throw new Error('ERR_LOGIN_FAILED')
}
// -> The handle is the user ID this server encoded at registration, so anything else is not a
// credential of ours
let userId: string
try {
userId = isoBase64URL.toUTF8String(userHandle)
} catch {
throw new Error('ERR_LOGIN_FAILED')
}
const user = await WIKI.models.users.getById(userId)
if (!user) {
WIKI.models.flags.authDebug(`Passkey login rejected: no user ${userId}`)
throw new Error('ERR_LOGIN_FAILED')
}
const store = (user.passkeys ?? {}) as PasskeyStore
const passkey = (store.authenticators ?? []).find((pk) => pk.id === authResponse.id)
if (!passkey) {
WIKI.models.flags.authDebug(
`Passkey login rejected: credential ${authResponse.id} is not registered for user ${userId}`
)
throw new Error('ERR_LOGIN_FAILED')
}
let verification
try {
verification = await verifyAuthenticationResponse({
response: authResponse,
expectedChallenge: pending.challenge,
expectedOrigin: pending.origin,
expectedRPID: pending.rpId,
// -> As at registration: the ceremony asked for `preferred`, so requiring it here would turn
// an authenticator that cannot verify into a login that never succeeds
requireUserVerification: false,
credential: {
id: passkey.id,
publicKey: isoBase64URL.toBuffer(passkey.publicKey),
counter: passkey.counter,
transports: passkey.transports
}
})
} catch (err: any) {
WIKI.models.flags.authDebug(
`Passkey login for user ${userId} failed to verify: ${err.message}`
)
throw new Error('ERR_LOGIN_FAILED')
}
if (!verification.verified) {
throw new Error('ERR_LOGIN_FAILED')
}
// -> The counter has to be stored for the replay check to mean anything next time
await this.saveStore(user.id, {
authenticators: (store.authenticators ?? []).map((pk) =>
pk.id === passkey.id ? { ...pk, counter: verification.authenticationInfo.newCounter } : pk
)
})
// -> Checks the password strategy would have made, which a passkey login would otherwise skip
if (!user.isActive) {
throw new Error('ERR_INACTIVE_USER')
}
if (!user.isVerified) {
throw new Error('ERR_USER_NOT_VERIFIED')
}
WIKI.models.flags.authDebug(
`User ${user.id} <${user.email}> authenticated with passkey "${passkey.name}"`
)
// -> Attributed to the local strategy, which is where an account's own credentials belong. Neither
// a password change nor a 2FA code is asked for on top of a passkey.
return WIKI.models.users.afterLoginChecks(
user,
WIKI.data.systemIds.localAuthId,
{ ip, siteId: pending.siteId },
{ skipTFA: true, skipChangePwd: true },
req
)
}
/**
* The stored blob for a user, or an empty one for a user who has never registered a passkey.
*/
async getStore(userId: string): Promise<PasskeyStore> {
const user = await WIKI.models.users.getById(userId)
return (user?.passkeys ?? {}) as PasskeyStore
}
/**
* Replace a user's stored passkey blob.
*/
async saveStore(userId: string, store: PasskeyStore): Promise<void> {
await WIKI.db
.update(usersTable)
.set({ passkeys: { authenticators: store.authenticators ?? [] }, updatedAt: sql`now()` })
.where(eq(usersTable.id, userId))
}
}
export const passkeys = new Passkeys()

@ -1,4 +1,5 @@
import bcrypt from 'bcryptjs'
import QRCode from 'qrcode'
import {
authentication as authenticationTable,
groups as groupsTable,
@ -12,6 +13,7 @@ import { and, count, eq, ilike, inArray, notExists, or, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { flatten, uniq } from 'es-toolkit/array'
import { detectImageMime, resizeImageToSquareJpeg } from '../helpers/images.ts'
import { buildTotpUri, generateTotpSecret, verifyTotpCode } from '../helpers/totp.ts'
import type { SystemIds } from './types.ts'
/** The essential user fields, mirroring the `UserCore` API schema. */
@ -37,7 +39,7 @@ export interface UserPage {
/**
* An authentication provider linked to a user, as exposed by the API. Secrets held in the stored
* `auth` blob (the password hash, the TFA secret) are never included `isPasswordSet` and
* `tfaIsActive` report their state instead.
* `isTfaSetup` report their state instead.
*/
export interface UserAuthProvider {
authId: string
@ -47,6 +49,28 @@ export interface UserAuthProvider {
config: Record<string, any>
}
/**
* One authentication provider as the user's own profile page sees it: enough to render what can be
* done with it, and nothing else. Unlike the administrator's view this carries no provider flags
* only whether a password exists, whether 2FA is set up, and whether the user is allowed to turn it
* off again.
*/
export interface UserProfileAuthMethod {
authId: string
authName: string
strategyKey: string
strategyIcon: string
config: {
isPasswordSet: boolean
isTfaSetup: boolean
isTfaRequired: boolean
/** False once password login has been turned off, whether by the user or by an administrator. */
isPasswordLoginEnabled: boolean
/** Whether the account has another way in, and may therefore turn password login off. */
canDisablePasswordLogin: boolean
}
}
/** The subset of user fields that may be modified. `isSystem` is deliberately absent. */
export interface UserPatch {
name?: string
@ -108,6 +132,64 @@ function escapeLikePattern(value: string): string {
return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')
}
/**
* Count a wrong 2FA code against a continuation token, destroying the token once `maxTfaAttempts`
* have been used up the client then has nothing left to continue with and has to start over.
*
* A token that has already been destroyed, or never existed, is not an error here: the caller is
* about to reject the attempt either way.
*/
async function countTfaFailure(token: string): Promise<void> {
const rows = await WIKI.db
.select({ id: userKeys.id, meta: userKeys.meta, userId: userKeys.userId })
.from(userKeys)
.where(eq(userKeys.token, token))
.limit(1)
const row = rows[0]
if (!row) {
return
}
const meta = (row.meta ?? {}) as Record<string, any>
const attempts = (meta.attempts ?? 0) + 1
if (attempts >= maxTfaAttempts) {
await WIKI.db.delete(userKeys).where(eq(userKeys.id, row.id))
WIKI.models.flags.authDebug(
`Discarded the 2FA continuation token of user ${row.userId} after ${attempts} incorrect codes`
)
return
}
await WIKI.db
.update(userKeys)
.set({ meta: { ...meta, attempts } })
.where(eq(userKeys.id, row.id))
}
/**
* How many wrong 2FA codes a continuation token survives before it is destroyed and the user has to
* start the login over. Retries have to be allowed six digits get mistyped, and a code that rotates
* every 30 seconds is regularly entered a moment too late but an unlimited number of them against a
* token that lives for 24 hours is a code space small enough to walk through.
*/
const maxTfaAttempts = 5
/**
* How many ways into the account remain if the given provider stops working: the other providers
* linked to it, plus every registered passkey.
*
* A provider that is itself restricted does not count it is no way in either. Passkeys are counted
* whichever host they were registered against: on a multi-site instance one bound to another site
* still leaves the account reachable, which is what this guards against.
*/
function countAlternativeLogins(user: any, strategyId: string): number {
const auth = (user.auth ?? {}) as Record<string, any>
const otherProviders = Object.entries(auth).filter(
([id, config]) => id !== strategyId && !config?.restrictLogin
).length
const passkeys = ((user.passkeys ?? {}).authenticators ?? []).length
return otherProviders + passkeys
}
/** Selection shared by the list / detail queries. Never includes `auth` or `passkeys`. */
const userSelection = {
id: usersTable.id,
@ -211,7 +293,7 @@ class Users {
* Fetch a single user with the groups it belongs to and the authentication providers linked to it.
*
* The stored `auth` blob is keyed by strategy ID and holds secrets, so it is reshaped into a list
* of providers carrying only state (`isPasswordSet`, `tfaIsActive`) never the password hash or
* of providers carrying only state (`isPasswordSet`, `isTfaSetup`) never the password hash or
* the TFA secret.
*
* @param id User ID
@ -233,7 +315,7 @@ class Users {
)) {
const strategy = strategies.find((s: any) => s.id === strategyId)
const definition = WIKI.data.authentication?.find((d: any) => d.key === strategy?.module)
const { password, tfaSecret, ...config } = rawConfig ?? {}
const { password, tfaSecret, tfaIsActive, tfaRequired, ...config } = rawConfig ?? {}
auth.push({
authId: strategyId,
authName: strategy?.displayName || definition?.title || strategy?.module || 'Unknown',
@ -242,7 +324,11 @@ class Users {
config: {
...config,
isPasswordSet: Boolean(password),
tfaIsActive: Boolean(tfaSecret)
// -> Named as the profile page's own view names them, so one piece of state is not called two
// things across the API. Whether 2FA is set up is `tfaIsActive` and a stored secret both:
// a secret that was generated but never confirmed is not 2FA being on.
isTfaSetup: Boolean(tfaIsActive && tfaSecret),
isTfaRequired: Boolean(tfaRequired)
}
})
}
@ -639,6 +725,250 @@ class Users {
return true
}
/**
* The authentication providers linked to a user, as its own profile page shows them.
*
* Reshaped from the stored `auth` blob the same way `getUserDetail()` does it, but reporting only
* what the user may act on. `isTfaRequired` is what greys out the "turn off 2FA" button, so it
* accounts for the strategy enforcing 2FA for everyone as well as this user being flagged for it.
*/
async getProfileAuthMethods(userId: string): Promise<UserProfileAuthMethod[]> {
const user = await this.getById(userId)
if (!user) {
return []
}
const strategies = await WIKI.db.select().from(authenticationTable)
const methods: UserProfileAuthMethod[] = []
for (const [strategyId, rawConfig] of Object.entries(
(user.auth ?? {}) as Record<string, any>
)) {
const strategy = strategies.find((s: any) => s.id === strategyId)
const definition = WIKI.data.authentication?.find((d: any) => d.key === strategy?.module)
const config = rawConfig ?? {}
methods.push({
authId: strategyId,
authName: strategy?.displayName || definition?.title || strategy?.module || 'Unknown',
strategyKey: strategy?.module ?? 'unknown',
strategyIcon: definition?.icon ?? '',
config: {
isPasswordSet: Boolean(config.password),
isTfaSetup: Boolean(config.tfaIsActive && config.tfaSecret),
isTfaRequired: Boolean(
config.tfaRequired || (strategy?.config as Record<string, any>)?.enforceTfa
),
isPasswordLoginEnabled: !config.restrictLogin,
canDisablePasswordLogin: countAlternativeLogins(user, strategyId) > 0
}
})
}
return methods
}
/**
* Change a user's own password, having checked the current one.
*
* Distinct from `setUserPassword()`, which is an administrator replacing a password it does not
* know. This also clears `mustChangePwd`: a user who has just chosen a password satisfies the
* requirement to choose one.
*
* @throws `ERR_INVALID_USER`, `ERR_INVALID_STRATEGY`, `ERR_PASSWORD_TOO_SHORT` or
* `ERR_INCORRECT_CURRENT_PASSWORD`
*/
async changeOwnPassword({
userId,
strategyId,
currentPassword,
newPassword
}: {
userId: string
strategyId: string
currentPassword: string
newPassword: string
}): Promise<void> {
const user = await this.getById(userId)
if (!user) {
throw new Error('ERR_INVALID_USER')
}
if (!newPassword || newPassword.length < 8) {
throw new Error('ERR_PASSWORD_TOO_SHORT')
}
const auth = (user.auth ?? {}) as Record<string, any>
// -> Only a provider that stores a password here has one to change; an external identity provider
// holds it somewhere this instance cannot reach
if (!auth[strategyId]?.password) {
throw new Error('ERR_INVALID_STRATEGY')
}
if ((await bcrypt.compare(currentPassword, auth[strategyId].password)) !== true) {
WIKI.models.flags.authDebug(
`Password change for user ${userId} rejected: the current password did not match`
)
throw new Error('ERR_INCORRECT_CURRENT_PASSWORD')
}
auth[strategyId] = {
...auth[strategyId],
password: await bcrypt.hash(newPassword, 12),
mustChangePwd: false
}
await WIKI.db
.update(usersTable)
.set({ auth, updatedAt: sql`now()` })
.where(eq(usersTable.id, userId))
}
/**
* Turn password login on or off for a user's own account, which is the same `restrictLogin` flag an
* administrator sets from the admin area.
*
* Turning it off is refused unless something else can still sign the account in a passkey or
* another linked provider because the alternative is a user locking themselves out of their own
* account with one click. Turning it back on needs no such check, and the password itself is neither
* cleared nor asked for: a session that got this far has already been authenticated.
*
* @throws `ERR_INVALID_USER`, `ERR_INVALID_STRATEGY`, `ERR_PASSWORD_LOGIN_NOT_APPLICABLE` or
* `ERR_NO_OTHER_LOGIN_METHOD`
*/
async setPasswordLoginEnabled({
userId,
strategyId,
isEnabled
}: {
userId: string
strategyId: string
isEnabled: boolean
}): Promise<void> {
const user = await this.getById(userId)
if (!user) {
throw new Error('ERR_INVALID_USER')
}
const auth = (user.auth ?? {}) as Record<string, any>
if (!auth[strategyId]) {
throw new Error('ERR_INVALID_STRATEGY')
}
// -> The flag is only ever read by the local module's `authenticate()`, so setting it on a provider
// that authenticates elsewhere would be a switch connected to nothing
const strategy = await WIKI.models.authentication.getStrategyById(strategyId)
if (strategy?.module !== 'local' || !auth[strategyId].password) {
throw new Error('ERR_PASSWORD_LOGIN_NOT_APPLICABLE')
}
if (!isEnabled && countAlternativeLogins(user, strategyId) < 1) {
throw new Error('ERR_NO_OTHER_LOGIN_METHOD')
}
auth[strategyId] = { ...auth[strategyId], restrictLogin: !isEnabled }
await WIKI.db
.update(usersTable)
.set({ auth, updatedAt: sql`now()` })
.where(eq(usersTable.id, userId))
WIKI.models.flags.authDebug(
`User ${userId} <${user.email}> turned password login ${isEnabled ? 'on' : 'off'}`
)
}
/**
* Start 2FA setup for a user: store a fresh secret, inactive, and return the QR code to scan.
*
* The secret is stored before it is proven to work, because the user has to be able to scan it and
* come back with a code generated from it. It counts for nothing until `enableTfa()` marks it
* active, and starting the setup again simply replaces it.
*
* @param user The user row, whose `auth` blob is updated in place as well as saved
* @param siteId The site being logged into, which names the entry in the authenticator app
* @returns The QR code as an SVG document, and the secret it encodes which is shown as text too,
* for a user who would rather type it into an authenticator app than scan anything
*/
async startTfaSetup(
user: any,
strategyId: string,
siteId?: string
): Promise<{ secret: string; tfaQRImage: string }> {
WIKI.logger.debug(`Generating a new 2FA secret for user ${user.id}...`)
// -> The title is only a label in the user's authenticator app, so any site will do when the one
// being logged into cannot be resolved
const site = (siteId ? WIKI.sites[siteId] : null) ?? Object.values(WIKI.sites ?? {})[0]
const issuer = (site as any)?.config?.title || 'Wiki'
const secret = generateTotpSecret()
user.auth = (user.auth ?? {}) as Record<string, any>
user.auth[strategyId] = {
...user.auth[strategyId],
tfaSecret: secret,
tfaIsActive: false
}
await WIKI.db
.update(usersTable)
.set({ auth: user.auth, updatedAt: sql`now()` })
.where(eq(usersTable.id, user.id))
return {
secret,
tfaQRImage: await QRCode.toString(buildTotpUri({ secret, account: user.email, issuer }), {
type: 'svg',
margin: 1
})
}
}
/**
* Mark a user's stored 2FA secret as active, i.e. required from now on. Called once the user has
* proven it produces the codes this server expects.
*/
async enableTfa(user: any, strategyId: string): Promise<void> {
user.auth[strategyId] = { ...user.auth[strategyId], tfaIsActive: true }
await WIKI.db
.update(usersTable)
.set({ auth: user.auth, updatedAt: sql`now()` })
.where(eq(usersTable.id, user.id))
WIKI.models.flags.authDebug(`User ${user.id} <${user.email}> enabled 2FA`)
}
/**
* Turn 2FA off for a user and forget the secret, so that setting it up again starts from a new one.
*
* @throws `ERR_INVALID_USER`, `ERR_INVALID_STRATEGY`, `ERR_TFA_NOT_ACTIVE` or `ERR_TFA_ENFORCED`
*/
async disableTfa(userId: string, strategyId: string): Promise<void> {
const user = await this.getById(userId)
if (!user) {
throw new Error('ERR_INVALID_USER')
}
const auth = (user.auth ?? {}) as Record<string, any>
if (!auth[strategyId]) {
throw new Error('ERR_INVALID_STRATEGY')
}
if (!auth[strategyId].tfaIsActive) {
throw new Error('ERR_TFA_NOT_ACTIVE')
}
// -> Turning it off would be undone at the next login, which is worth an error rather than a
// confusing round trip. The client greys the button out, but that is a client.
const strategy = await WIKI.models.authentication.getStrategyById(strategyId)
if (auth[strategyId].tfaRequired || (strategy?.config as Record<string, any>)?.enforceTfa) {
throw new Error('ERR_TFA_ENFORCED')
}
auth[strategyId] = { ...auth[strategyId], tfaIsActive: false, tfaSecret: '' }
await WIKI.db
.update(usersTable)
.set({ auth, updatedAt: sql`now()` })
.where(eq(usersTable.id, userId))
WIKI.models.flags.authDebug(`User ${userId} <${user.email}> disabled 2FA`)
}
/**
* Whether a security code matches the 2FA secret stored for a user under one strategy.
*/
verifyTfaCode(user: any, strategyId: string, securityCode: string): boolean {
const secret = ((user.auth ?? {}) as Record<string, any>)[strategyId]?.tfaSecret
return Boolean(secret) && verifyTotpCode(secret, securityCode)
}
/**
* Delete a user.
*
@ -822,10 +1152,7 @@ class Users {
if (!skipTFA) {
if (authStr.tfaIsActive && authStr.tfaSecret) {
try {
// FIXME: pre-existing bug — `WIKI.db.userKeys` is leftover Objection.js API and does not
// exist on a Drizzle instance, so this throws a TypeError. The intended call is
// `this.generateToken({ ... })`, as used further down in this same file.
const tfaToken = await (WIKI.db as any).userKeys.generateToken({
const tfaToken = await this.generateToken({
kind: 'tfa',
userId: user.id,
meta: {
@ -842,15 +1169,12 @@ class Users {
}
} catch (errc) {
WIKI.logger.warn(errc)
throw new WIKI.Error.AuthGenericError()
throw new Error('ERR_TFA_FAILED')
}
} else if (str.config?.enforceTfa || authStr.tfaRequired) {
try {
const tfaQRImage = await user.generateTFA(strategyId, context.siteId)
// FIXME: pre-existing bug — `WIKI.db.userKeys` is leftover Objection.js API and does not
// exist on a Drizzle instance, so this throws a TypeError. The intended call is
// `this.generateToken({ ... })`, as used further down in this same file.
const tfaToken = await (WIKI.db as any).userKeys.generateToken({
const { tfaQRImage } = await this.startTfaSetup(user, strategyId, context.siteId)
const tfaToken = await this.generateToken({
kind: 'tfaSetup',
userId: user.id,
meta: {
@ -868,7 +1192,7 @@ class Users {
}
} catch (errc) {
WIKI.logger.warn(errc)
throw new WIKI.Error.AuthGenericError()
throw new Error('ERR_TFA_FAILED')
}
}
}
@ -894,7 +1218,7 @@ class Users {
}
} catch (errc) {
WIKI.logger.warn(errc)
throw new WIKI.Error.AuthGenericError()
throw new Error('ERR_CHANGE_PASSWORD_FAILED')
}
}
@ -924,6 +1248,153 @@ class Users {
}
}
/**
* Finish a login that stopped for 2FA either to ask for a code, or to have the user set 2FA up
* because the strategy or the account requires it.
*
* The continuation token identifies the half-finished login, and is kept rather than consumed while
* codes are being tried: a mistyped or just-expired code has to be retryable. It is destroyed here
* as soon as one is correct, and by `countTfaFailure()` once too many have not been.
*
* @param setup True when the token came from a required setup, in which case a correct code also
* activates the secret that was generated for it
* @throws `ERR_TFA_INVALID_REQUEST`, `ERR_INVALID_USER`, `ERR_INVALID_STRATEGY` or
* `ERR_TFA_INCORRECT_TOKEN`, plus whatever `validateToken()` raises for a token that is
* unknown or expired
*/
async loginTFA(
{
strategyId,
siteId,
securityCode,
continuationToken,
setup = false,
ip
}: {
strategyId: string
siteId: string
securityCode: string
continuationToken: string
setup?: boolean
ip?: string
},
req: any
): Promise<AfterLoginResult> {
if (!continuationToken || !/^[0-9]{6}$/.test(securityCode)) {
throw new Error('ERR_TFA_INVALID_REQUEST')
}
const { user, strategyId: expectedStrategyId } = await this.validateToken({
kind: setup ? 'tfaSetup' : 'tfa',
token: continuationToken,
skipDelete: true
})
if (!user) {
throw new Error('ERR_INVALID_USER')
}
if (strategyId !== expectedStrategyId) {
throw new Error('ERR_INVALID_STRATEGY')
}
if (!this.verifyTfaCode(user, strategyId, securityCode)) {
await countTfaFailure(continuationToken)
WIKI.models.flags.authDebug(`User ${user.id} <${user.email}> submitted an incorrect 2FA code`)
throw new Error('ERR_TFA_INCORRECT_TOKEN')
}
await this.destroyToken({ token: continuationToken })
if (setup) {
await this.enableTfa(user, strategyId)
}
// -> The remaining checks still apply: a user who owed a password change before 2FA still owes it
return this.afterLoginChecks(user, strategyId, { ip, siteId }, { skipTFA: true }, req)
}
/**
* Start 2FA setup from the profile page, for a user who is already logged in.
*
* @returns The QR code to scan, the secret behind it for manual entry, and the token that
* `confirmTfaSetup()` expects back
* @throws `ERR_INVALID_USER`, `ERR_INVALID_STRATEGY` or `ERR_TFA_ALREADY_ACTIVE`
*/
async startProfileTfaSetup({
userId,
strategyId,
siteId
}: {
userId: string
strategyId: string
siteId?: string
}): Promise<{ continuationToken: string; tfaQRImage: string; tfaSecret: string }> {
const user = await this.getById(userId)
if (!user) {
throw new Error('ERR_INVALID_USER')
}
const auth = (user.auth ?? {}) as Record<string, any>
if (!auth[strategyId]) {
throw new Error('ERR_INVALID_STRATEGY')
}
// -> Replacing a working secret would silently invalidate the app entry the user already has;
// turning 2FA off first is the way to start again
if (auth[strategyId].tfaIsActive) {
throw new Error('ERR_TFA_ALREADY_ACTIVE')
}
const { secret, tfaQRImage } = await this.startTfaSetup(user, strategyId, siteId)
const continuationToken = await this.generateToken({
kind: 'tfaSetup',
userId,
meta: { strategyId }
})
return { continuationToken, tfaQRImage, tfaSecret: secret }
}
/**
* Finish 2FA setup from the profile page: check a code from the user's authenticator, then activate
* the secret that was generated for it.
*
* Deliberately not `loginTFA()` with `setup`: the user is already logged in, and running the login
* checks again would rebuild the session and emit a second login event for one visit.
*
* @throws `ERR_TFA_INVALID_REQUEST`, `ERR_INVALID_USER`, `ERR_INVALID_STRATEGY` or
* `ERR_TFA_INCORRECT_TOKEN`
*/
async confirmTfaSetup({
userId,
strategyId,
continuationToken,
securityCode
}: {
userId: string
strategyId: string
continuationToken: string
securityCode: string
}): Promise<void> {
if (!continuationToken || !/^[0-9]{6}$/.test(securityCode)) {
throw new Error('ERR_TFA_INVALID_REQUEST')
}
const { user, strategyId: expectedStrategyId } = await this.validateToken({
kind: 'tfaSetup',
token: continuationToken,
skipDelete: true
})
// -> The token is a bearer credential, so it only counts for the session that asked for it
if (!user || user.id !== userId) {
throw new Error('ERR_INVALID_USER')
}
if (strategyId !== expectedStrategyId) {
throw new Error('ERR_INVALID_STRATEGY')
}
if (!this.verifyTfaCode(user, strategyId, securityCode)) {
await countTfaFailure(continuationToken)
throw new Error('ERR_TFA_INCORRECT_TOKEN')
}
await this.destroyToken({ token: continuationToken })
await this.enableTfa(user, strategyId)
}
/**
* Where to send a user after logging out.
*

@ -14,13 +14,9 @@ props:
enforceTfa:
type: Boolean
title: Enforce Two-Factor Authentication
# Read-only until 2FA works end to end: `afterLoginChecks` reaches for a `generateTFA()` that does
# not exist, and there is no route to submit a code, so a login that needs 2FA can only fail.
# See the FIXME comments in models/users.ts.
hint: Not available yet — two-factor authentication is not implemented in this version.
hint: Users will be required to set up 2FA the first time they login, and cannot turn it off afterwards.
icon: pin-pad
default: false
readOnly: true
emailValidation:
type: Boolean
title: Email Validation

@ -23,11 +23,13 @@
"@fastify/view": "12.0.0",
"@gquittet/graceful-server": "6.0.10",
"@iconify/utils": "3.1.4",
"@simplewebauthn/server": "13.3.2",
"ajv-formats": "3.0.1",
"bcryptjs": "3.0.3",
"chalk": "5.6.2",
"cheerio": "1.2.0",
"cron-parser": "5.5.0",
"diff": "9.0.0",
"drizzle-orm": "1.0.0-beta.15-859cf75",
"emittery": "2.0.0",
"es-toolkit": "1.47.1",
@ -43,6 +45,7 @@
"pg": "8.21.0",
"poolifier": "5.3.2",
"pug": "3.0.4",
"qrcode": "1.5.4",
"sanitize-html": "2.17.6",
"semver": "7.8.4",
"uuid": "14.0.0"
@ -54,6 +57,7 @@
"@types/pem-jwk": "2.0.2",
"@types/pg": "8.20.0",
"@types/pug": "2.0.10",
"@types/qrcode": "1.5.6",
"@types/sanitize-html": "2.16.1",
"@types/semver": "7.7.1",
"drizzle-kit": "1.0.0-beta.15-859cf75",
@ -1339,6 +1343,12 @@
"fsevents": "^2.3.3"
}
},
"node_modules/@hexagon/base64": {
"version": "1.1.28",
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
"license": "MIT"
},
"node_modules/@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
@ -1925,6 +1935,12 @@
"node": ">=12"
}
},
"node_modules/@levischuck/tiny-cbor": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
"license": "MIT"
},
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
@ -2580,12 +2596,199 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@peculiar/asn1-android": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.8.0.tgz",
"integrity": "sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-cms": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz",
"integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"@peculiar/asn1-x509-attr": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-csr": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz",
"integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz",
"integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pfx": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz",
"integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.8.0",
"@peculiar/asn1-pkcs8": "^2.8.0",
"@peculiar/asn1-rsa": "^2.8.0",
"@peculiar/asn1-schema": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs8": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz",
"integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs9": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz",
"integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.8.0",
"@peculiar/asn1-pfx": "^2.8.0",
"@peculiar/asn1-pkcs8": "^2.8.0",
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"@peculiar/asn1-x509-attr": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz",
"integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz",
"integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==",
"license": "MIT",
"dependencies": {
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz",
"integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509-attr": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz",
"integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/x509": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.6.0",
"@peculiar/asn1-csr": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.0",
"@peculiar/asn1-pkcs9": "^2.6.0",
"@peculiar/asn1-rsa": "^2.6.0",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"pvtsutils": "^1.3.6",
"reflect-metadata": "^0.2.2",
"tslib": "^2.8.1",
"tsyringe": "^4.10.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@simplewebauthn/server": {
"version": "13.3.2",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz",
"integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==",
"license": "MIT",
"dependencies": {
"@hexagon/base64": "^1.1.27",
"@levischuck/tiny-cbor": "^0.2.2",
"@peculiar/asn1-android": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.1",
"@peculiar/asn1-rsa": "^2.6.1",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.1",
"@peculiar/x509": "^1.14.3"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@tediousjs/connection-string": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-0.5.0.tgz",
@ -2668,6 +2871,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/readable-stream": {
"version": "4.0.23",
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz",
@ -3117,6 +3330,30 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@ -3168,6 +3405,20 @@
"safer-buffer": "^2.1.0"
}
},
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.5",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/assert-never": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.4.0.tgz",
@ -3388,6 +3639,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
@ -3489,6 +3749,17 @@
"node": ">= 6"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
@ -3498,6 +3769,24 @@
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/commander": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
@ -3622,6 +3911,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@ -3702,6 +4000,21 @@
"node": ">=8"
}
},
"node_modules/diff": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz",
"integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/doctypes": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz",
@ -4026,6 +4339,12 @@
"url": "https://github.com/sindresorhus/emittery?sponsor=1"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/encoding-sniffer": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
@ -4379,6 +4698,19 @@
"node": ">=20"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -4425,6 +4757,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@ -4769,6 +5110,15 @@
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@ -5062,6 +5412,18 @@
],
"license": "MIT"
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@ -5558,6 +5920,42 @@
}
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-manager-detector": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz",
@ -5619,6 +6017,15 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@ -5799,6 +6206,15 @@
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/poolifier": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/poolifier/-/poolifier-5.3.2.tgz",
@ -6075,6 +6491,41 @@
"integrity": "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==",
"license": "MIT"
},
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
@ -6132,6 +6583,21 @@
"node": ">= 12.13.0"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@ -6141,6 +6607,12 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@ -6400,6 +6872,12 @@
"node": ">=10"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
@ -6546,6 +7024,32 @@
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@ -6720,6 +7224,24 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsyringe": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
"license": "MIT",
"dependencies": {
"tslib": "^1.9.3"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/tsyringe/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
@ -6871,6 +7393,12 @@
"node": ">=18"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/with": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/with/-/with-7.0.2.tgz",
@ -6886,6 +7414,20 @@
"node": ">= 10.0.0"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@ -6917,6 +7459,12 @@
"node": ">=0.4"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
@ -6931,6 +7479,41 @@
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
}
}
}

@ -49,11 +49,13 @@
"@fastify/view": "12.0.0",
"@gquittet/graceful-server": "6.0.10",
"@iconify/utils": "3.1.4",
"@simplewebauthn/server": "13.3.2",
"ajv-formats": "3.0.1",
"bcryptjs": "3.0.3",
"chalk": "5.6.2",
"cheerio": "1.2.0",
"cron-parser": "5.5.0",
"diff": "9.0.0",
"drizzle-orm": "1.0.0-beta.15-859cf75",
"emittery": "2.0.0",
"es-toolkit": "1.47.1",
@ -69,6 +71,7 @@
"pg": "8.21.0",
"poolifier": "5.3.2",
"pug": "3.0.4",
"qrcode": "1.5.4",
"sanitize-html": "2.17.6",
"semver": "7.8.4",
"uuid": "14.0.0"
@ -83,6 +86,7 @@
"@types/pem-jwk": "2.0.2",
"@types/pg": "8.20.0",
"@types/pug": "2.0.10",
"@types/qrcode": "1.5.6",
"@types/sanitize-html": "2.16.1",
"@types/semver": "7.7.1",
"drizzle-kit": "1.0.0-beta.15-859cf75",

@ -8,6 +8,7 @@
import 'fastify'
import '@fastify/session'
import type { ApiKeyIdentity } from '../models/apiKeys.ts'
import type { PasskeyChallenge } from '../models/passkeys.ts'
declare module 'fastify' {
interface FastifyRequest {
@ -42,6 +43,17 @@ declare module 'fastify' {
* it the client is never trusted with that state.
*/
unlockedPages?: string[]
/**
* The WebAuthn challenge a passkey ceremony is waiting on, written by the routes in `api/users.ts`
* (registration) and `api/authentication.ts` (login) and consumed by the verification that
* follows.
*
* It lives on the session because a login challenge belongs to nobody yet: a passkey identifies
* the account it signs for, so the server has no idea who is signing in until the assertion comes
* back. Two fields rather than one, so that neither ceremony can consume the other's challenge.
*/
passkeyRegistration?: PasskeyChallenge
passkeyLogin?: PasskeyChallenge
}
interface FastifyContextConfig {

@ -60,13 +60,6 @@ declare global {
sites: Record<string, any>
sitesMappings: Record<string, string>
/**
* FIXME: never assigned anywhere in the codebase. The three
* `throw new WIKI.Error.AuthGenericError()` sites in models/users.ts therefore raise a
* TypeError rather than the intended error. Declared only so the migration can typecheck.
*/
Error: any
/** Only present in worker threads (see worker.ts) */
ensureDb?: () => Promise<boolean | void>
}

@ -0,0 +1,269 @@
<template>
<w-dialog v-model="dialogVisible" @hide="onDialogHide">
<w-card style="min-width: 650px">
<w-card-section class="card-header">
<w-icon name="img:/_assets/icons/fluent-inspection.svg" size="sm" class="mr-2" />
<span>{{ isEdit ? t('admin.approval.editRule') : t('admin.approval.newRule') }}</span>
</w-card-section>
<w-form ref="ruleForm" class="py-2" @submit="save">
<w-item>
<blueprint-icon icon="rename" class="self-start" />
<w-item-section>
<w-input
ref="iptName"
v-model="state.name"
outlined
dense
:rules="nameValidation"
hide-bottom-space
:label="t(`admin.approval.name`)"
:hint="t(`admin.approval.nameHint`)"
lazy-rules="ondemand"
autofocus />
</w-item-section>
</w-item>
<w-item>
<blueprint-icon icon="filtration" class="self-start" />
<w-item-section>
<w-select
v-model="state.match"
outlined
dense
:options="matchOptions"
map-options
emit-value
option-value="value"
option-label="label"
options-dense
hide-bottom-space
:label="t(`admin.approval.match`)"
:hint="t(`admin.approval.matchHint`)" />
</w-item-section>
</w-item>
<w-item>
<blueprint-icon :icon="isTagMatch ? `flag-filled` : `link`" class="self-start" />
<w-item-section>
<!--
One field for both kinds of pattern: a tag mode takes a list of tags rather than a path,
so only its label, hint and rule change.
-->
<w-input
v-model="state.path"
outlined
dense
:prefix="isTagMatch ? null : `/`"
:suffix="state.match === `REGEX` ? `/` : null"
:rules="pathValidation"
hide-bottom-space
:label="isTagMatch ? t(`admin.approval.tags`) : t(`admin.approval.path`)"
:hint="isTagMatch ? t(`admin.approval.tagsHint`) : t(`admin.approval.pathHint`)"
lazy-rules="ondemand" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="pen" class="self-start" />
<w-item-section>
<w-select
v-model="state.submitterGroups"
outlined
dense
:options="props.groups"
multiple
map-options
emit-value
option-value="id"
option-label="name"
options-dense
:rules="groupsValidation(t(`admin.approval.submittersRequired`))"
hide-bottom-space
:label="t(`admin.approval.submitters`)"
:hint="t(`admin.approval.submittersHint`)"
lazy-rules="ondemand" />
</w-item-section>
</w-item>
<w-item>
<blueprint-icon icon="validation" class="self-start" />
<w-item-section>
<w-select
v-model="state.reviewerGroups"
outlined
dense
:options="props.groups"
multiple
map-options
emit-value
option-value="id"
option-label="name"
options-dense
:rules="groupsValidation(t(`admin.approval.reviewersRequired`))"
hide-bottom-space
:label="t(`admin.approval.reviewers`)"
:hint="t(`admin.approval.reviewersHint`)"
lazy-rules="ondemand" />
</w-item-section>
</w-item>
</w-form>
<w-card-actions class="card-actions">
<w-space />
<w-btn
class="acrylic-btn"
flat
:label="t(`common.actions.cancel`)"
color="grey"
padding="xs md"
@click="onDialogCancel" />
<w-btn
unelevated
:label="isEdit ? t(`common.actions.save`) : t(`common.actions.create`)"
color="primary"
padding="xs md"
:loading="state.isLoading"
@click="save" />
</w-card-actions>
</w-card>
</w-dialog>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { computed, reactive, ref } from 'vue'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify'
// PROPS
const props = defineProps({
siteId: {
type: String,
required: true
},
/** The rule being edited, or null to create one. */
rule: {
type: Object,
default: null
},
/** The groups to choose from, loaded once by the page rather than per dialog. */
groups: {
type: Array,
default: () => []
}
})
// EMITS
defineEmits([...dialogComponentEmits])
// DIALOG
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent({
autofocus: () => iptName.value
})
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
name: props.rule?.name ?? '',
match: props.rule?.match ?? 'START',
path: props.rule?.path ?? '',
submitterGroups: [...(props.rule?.submitterGroups ?? [])],
reviewerGroups: [...(props.rule?.reviewerGroups ?? [])],
isLoading: false
})
// REFS
const ruleForm = ref(null)
const iptName = ref(null)
// COMPUTED
const isEdit = computed(() => Boolean(props.rule))
const isTagMatch = computed(() => ['TAG', 'TAGALL'].includes(state.match))
const matchOptions = computed(() => [
{ label: t('admin.approval.matchStart'), value: 'START' },
{ label: t('admin.approval.matchExact'), value: 'EXACT' },
{ label: t('admin.approval.matchEnd'), value: 'END' },
{ label: t('admin.approval.matchRegex'), value: 'REGEX' },
{ label: t('admin.approval.matchTag'), value: 'TAG' },
{ label: t('admin.approval.matchTagAll'), value: 'TAGALL' }
])
// VALIDATION RULES
const nameValidation = [(val) => (val ?? '').trim().length > 0 || t('admin.approval.nameRequired')]
const pathValidation = [
(val) =>
(val ?? '').trim().length > 0 ||
(isTagMatch.value ? t('admin.approval.tagsRequired') : t('admin.approval.pathRequired')),
// -> Caught here as well as by the server: a pattern that cannot compile is a rule that silently
// covers nothing, and the message is far more useful next to the field
(val) => {
if (state.match !== 'REGEX') {
return true
}
try {
new RegExp(val)
return true
} catch (err) {
return t('admin.approval.pathInvalidRegex', { reason: err.message })
}
}
]
const groupsValidation = (message) => [(val) => (val ?? []).length > 0 || message]
// METHODS
async function save() {
state.isLoading = true
try {
const isFormValid = await ruleForm.value.validate(true)
if (!isFormValid) {
throw new Error(t('admin.approval.formInvalid'))
}
// -> `isEnabled` is deliberately absent: the list row owns that switch, so a rule saved here keeps
// whatever state it already had, and a new one starts enabled
const payload = {
name: state.name.trim(),
match: state.match,
path: state.path.trim(),
submitterGroups: state.submitterGroups,
reviewerGroups: state.reviewerGroups
}
const resp = isEdit.value
? await API_CLIENT.put(`sites/${props.siteId}/approvals/rules/${props.rule.id}`, {
json: payload
}).json()
: await API_CLIENT.post(`sites/${props.siteId}/approvals/rules`, { json: payload }).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
notify({
type: 'positive',
message: isEdit.value ? t('admin.approval.updateSuccess') : t('admin.approval.createSuccess')
})
onDialogOK(resp.rule)
} catch (err) {
notify({
type: 'negative',
message:
(await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)) ?? err.message
})
}
state.isLoading = false
}
</script>

@ -1,11 +1,11 @@
<template>
<div class="auth-login">
<div>
<!-- ----------------------------------------------------- -->
<!-- LOGIN SCREEN -->
<!-- ----------------------------------------------------- -->
<template v-if="state.screen === `login`">
<template v-if="state.strategies?.length > 1">
<p>{{t('auth.selectAuthProvider')}}</p>
<p>{{ t('auth.selectAuthProvider') }}</p>
<div class="auth-strategies mb-4">
<w-btn
v-for="str of state.strategies"
@ -13,8 +13,16 @@
:icon="`img:` + str.activeStrategy.strategy.icon"
push
no-caps
:color="str.id === state.selectedStrategyId ? `primary` : (dark.isActive ? `blue-grey-9` : `grey-1`)"
:text-color="str.id === state.selectedStrategyId || dark.isActive ? `white` : `blue-grey-9`"
:color="
str.id === state.selectedStrategyId
? `primary`
: dark.isActive
? `blue-grey-9`
: `grey-1`
"
:text-color="
str.id === state.selectedStrategyId || dark.isActive ? `white` : `blue-grey-9`
"
@click="state.selectedStrategyId = str.id" />
</div>
</template>
@ -24,8 +32,14 @@
v-model="state.username"
autofocus
outlined
:label="t(`auth.fields.` + (selectedStrategy.activeStrategy?.strategy?.usernameType ?? `email`))"
:rules="selectedStrategy.activeStrategy?.strategy?.usernameType === `username` ? loginUsernameValidation : userEmailValidation"
:label="
t(`auth.fields.` + (selectedStrategy.activeStrategy?.strategy?.usernameType ?? `email`))
"
:rules="
selectedStrategy.activeStrategy?.strategy?.usernameType === `username`
? loginUsernameValidation
: userEmailValidation
"
lazy-rules="ondemand"
hide-bottom-space
:autocomplete="selectedStrategy.activeStrategy?.strategy?.usernameType ?? `email`">
@ -52,6 +66,11 @@
no-caps
icon="la:sign-in-alt" />
</w-form>
<!--
Straight into the browser's passkey prompt: a passkey is a discoverable credential, so the
authenticator knows which accounts it holds for this site and asking for an email address first
would only be a step in the way.
-->
<template v-if="canUsePasskeys">
<w-separator class="my-4" />
<w-btn
@ -61,7 +80,7 @@
:label="t(`auth.passkeys.signin`)"
no-caps
icon="la:key"
@click="switchTo(`passkey`)" />
@click="loginWithPasskey" />
</template>
<template v-if="selectedStrategy.activeStrategy?.strategy?.key === `local`">
<w-separator class="my-4" />
@ -85,44 +104,10 @@
</template>
</template>
<!-- ----------------------------------------------------- -->
<!-- PASSKEY LOGIN SCREEN -->
<!-- ----------------------------------------------------- -->
<template v-else-if="state.screen === `passkey`">
<p>{{t('auth.passkeys.signinHint')}}</p>
<w-form ref="passkeyForm" @submit="loginWithPasskey">
<w-input
ref="passkeyEmailIpt"
v-model="state.username"
outlined
hide-bottom-space
:label="t(`auth.fields.email`)"
autocomplete="webauthn">
<template #prepend><w-icon name="la:envelope" /></template>
</w-input>
<w-btn
class="w-full mt-2"
type="submit"
push
color="primary"
:label="t(`auth.actions.login`)"
no-caps
icon="la:key" />
</w-form>
<w-separator class="my-4" />
<w-btn
class="acrylic-btn w-full"
flat
color="primary"
:label="t(`auth.forgotPasswordCancel`)"
no-caps
icon="la:arrow-circle-left"
@click="switchTo(`login`)" />
</template>
<!-- ----------------------------------------------------- -->
<!-- FORGOT PASSWORD SCREEN -->
<!-- ----------------------------------------------------- -->
<template v-else-if="state.screen === `forgot`">
<p>{{t('auth.forgotPasswordSubtitle')}}</p>
<p>{{ t('auth.forgotPasswordSubtitle') }}</p>
<w-form ref="forgotForm" @submit="forgotPassword">
<w-input
ref="forgotEmailIpt"
@ -158,7 +143,7 @@
<!-- REGISTER SCREEN -->
<!-- ----------------------------------------------------- -->
<template v-else-if="state.screen === `register`">
<p>{{t('auth.registerSubTitle')}}</p>
<p>{{ t('auth.registerSubTitle') }}</p>
<w-form ref="registerForm" @submit="register">
<w-input
ref="registerNameIpt"
@ -236,7 +221,7 @@
<!-- CHANGE PASSWORD SCREEN -->
<!-- ----------------------------------------------------- -->
<template v-else-if="state.screen === `changePwd`">
<p v-if="state.continuationToken">{{t('auth.changePwd.instructions')}}</p>
<p v-if="state.continuationToken">{{ t('auth.changePwd.instructions') }}</p>
<w-form ref="changePwdForm" @submit="changePwd">
<w-input
v-if="!state.continuationToken"
@ -296,7 +281,7 @@
<!-- TFA SCREEN -->
<!-- ----------------------------------------------------- -->
<template v-else-if="state.screen === `tfa`">
<p>{{t('auth.tfa.subtitle')}}</p>
<p>{{ t('auth.tfa.subtitle') }}</p>
<v-otp-input
v-model:value="state.securityCode"
:num-inputs="6"
@ -318,12 +303,12 @@
<!-- TFA SETUP SCREEN -->
<!-- ----------------------------------------------------- -->
<template v-else-if="state.screen === `tfasetup`">
<p>{{t('auth.tfaSetupTitle')}}</p>
<p>{{t('auth.tfaSetupInstrFirst')}}</p>
<div style="justify-content: center; display: flex;">
<div v-html="state.tfaQRImage" style="width: 200px;" />
<p>{{ t('auth.tfaSetupTitle') }}</p>
<p>{{ t('auth.tfaSetupInstrFirst') }}</p>
<div style="justify-content: center; display: flex">
<div v-html="state.tfaQRImage" style="width: 200px" />
</div>
<p class="mt-2">{{t('auth.tfaSetupInstrSecond')}}</p>
<p class="mt-2">{{ t('auth.tfaSetupInstrSecond') }}</p>
<v-otp-input
v-model:value="state.securityCode"
:num-inputs="6"
@ -350,20 +335,16 @@ import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { loading } from '@/composables/loading'
import { notify } from '@/composables/notify'
import { useDark } from '@/composables/dark'
import { localizeError } from '@/helpers/localization'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
import Cookies from 'js-cookie'
import zxcvbn from 'zxcvbn'
import {
browserSupportsWebAuthn,
browserSupportsWebAuthnAutofill,
startAuthentication
} from '@simplewebauthn/browser'
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser'
import VOtpInput from 'vue3-otp-input'
// COMPOSABLES
const dark = useDark()
@ -399,7 +380,6 @@ const state = reactive({
// REFS
const loginEmailIpt = ref(null)
const passkeyEmailIpt = ref(null)
const forgotEmailIpt = ref(null)
const registerNameIpt = ref(null)
const changePwdCurrentIpt = ref(null)
@ -413,8 +393,7 @@ const changePwdForm = ref(null)
const selectedStrategy = computed(() => {
return (
(state.selectedStrategyId &&
state.strategies.find((s) => s.id === state.selectedStrategyId)) ||
(state.selectedStrategyId && state.strategies.find((s) => s.id === state.selectedStrategyId)) ||
{}
)
})
@ -488,6 +467,20 @@ const userPasswordVerifyValidation = [
// METHODS
/**
* The reason the API gave, untranslated: the `ERR_*` code out of a response ky threw on (anything
* above 400), or the error's own message when the request never got an answer. Kept as the raw code so
* that callers can both display it and act on it.
*/
async function apiError(err) {
return (
(await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)) ?? err.message
)
}
function switchTo(screen) {
switch (screen) {
case 'login': {
@ -497,13 +490,6 @@ function switchTo(screen) {
})
break
}
case 'passkey': {
state.screen = 'passkey'
nextTick(() => {
passkeyEmailIpt.value.focus()
})
break
}
case 'forgot': {
state.screen = 'forgot'
nextTick(() => {
@ -615,14 +601,14 @@ async function login() {
state.password = ''
handleLoginResponse(resp)
} else {
throw new Error(resp.message || t('auth.errors.loginError'))
throw new Error(resp.message || 'ERR_LOGIN_FAILED')
}
} catch (err) {
console.warn(err)
loading.hide()
notify({
type: 'negative',
message: err.message
message: localizeError(await apiError(err), t)
})
}
}
@ -635,77 +621,34 @@ async function loginWithPasskey() {
message: t('auth.signingIn')
})
try {
const respGen = await APOLLO_CLIENT.mutate({
mutation: `
mutation authenticatePasskeyGenerate (
$email: String!
$siteId: UUID!
) {
authenticatePasskeyGenerate (
email: $email
siteId: $siteId
) {
operation {
succeeded
message
}
authOptions
}
}
`,
variables: {
email: state.username,
siteId: siteStore.id
}
})
if (respGen.data?.authenticatePasskeyGenerate?.operation?.succeeded) {
const authResp = await startAuthentication(
respGen.data.authenticatePasskeyGenerate.authOptions,
await browserSupportsWebAuthnAutofill()
)
const respVerif = await APOLLO_CLIENT.mutate({
mutation: `
mutation authenticatePasskeyVerify (
$authResponse: JSON!
) {
authenticatePasskeyVerify (
authResponse: $authResponse
) {
operation {
succeeded
message
}
jwt
nextAction
continuationToken
redirect
tfaQRImage
}
}
`,
variables: {
authResponse: authResp
}
})
if (respVerif.data?.authenticatePasskeyVerify?.operation?.succeeded) {
handleLoginResponse(respVerif.data.authenticatePasskeyVerify)
} else {
throw new Error(
respVerif.data?.authenticatePasskeyVerify?.operation?.message ||
t('auth.errors.loginError')
)
const respGen = await API_CLIENT.post(`sites/${siteStore.id}/auth/passkey/challenge`).json()
if (!respGen?.ok) {
throw new Error(respGen?.message || 'ERR_LOGIN_FAILED')
}
// -> No `useBrowserAutofill`: that fills a passkey into a form field the user is typing in, and
// there is no field here -- this opens the browser's own account picker instead
const authResp = await startAuthentication({ optionsJSON: respGen.authOptions })
const respVerif = await API_CLIENT.put(`sites/${siteStore.id}/auth/passkey/login`, {
json: {
authResponse: authResp
}
} else {
throw new Error(
respGen.data?.authenticatePasskeyGenerate?.operation?.message || t('auth.errors.loginError')
)
}).json()
if (!respVerif?.ok) {
throw new Error(respVerif?.message || 'ERR_LOGIN_FAILED')
}
await handleLoginResponse(respVerif)
} catch (err) {
loading.hide()
// -> Dismissing the browser's passkey prompt is not a failure to report: the user asked for the
// prompt and then changed their mind, and is looking at the login form again either way
if (err.name === 'NotAllowedError' || err.name === 'AbortError') {
return
}
notify({
type: 'negative',
message: err.message
message: localizeError(await apiError(err), t)
})
}
}
@ -812,73 +755,73 @@ async function changePwd() {
})
await handleLoginResponse(resp)
} else {
throw new Error(resp.message || t('auth.errors.loginError'))
throw new Error(resp.message || 'ERR_CHANGE_PASSWORD_FAILED')
}
} catch (err) {
notify({
type: 'negative',
message: err.message
message: localizeError(await apiError(err), t)
})
}
}
/**
* VERIFY TFA TOKEN
* Send the security code for the login this panel is in the middle of.
*
* The continuation token is only cleared once the code is accepted: a mistyped one can be entered
* again, up to the handful of attempts the server allows before it discards the token.
*
* @param setup True on the setup screen, where a correct code also activates the new secret
* @returns The login response, to be handed to `handleLoginResponse()`
*/
async function submitTFA(setup) {
if (!/^[0-9]{6}$/.test(state.securityCode)) {
throw new Error(t('auth.errors.tfaMissing'))
}
const resp = await API_CLIENT.put(`sites/${siteStore.id}/auth/tfa`, {
json: {
strategyId: state.selectedStrategyId,
continuationToken: state.continuationToken,
securityCode: state.securityCode,
setup
}
}).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'ERR_LOGIN_FAILED')
}
state.continuationToken = ''
state.securityCode = ''
return resp
}
/**
* Report a failed 2FA attempt, and start the login over when there is nothing left to continue: an
* expired token, or one the server has discarded after too many wrong codes, leaves this screen with
* no way forward.
*/
async function handleTFAError(err) {
const code = await apiError(err)
loading.hide()
notify({
type: 'negative',
message: localizeError(code, t)
})
if (code === 'ERR_INVALID_VALIDATION_TOKEN' || code === 'ERR_EXPIRED_VALIDATION_TOKEN') {
state.continuationToken = ''
state.securityCode = ''
state.password = ''
switchTo('login')
}
}
async function verifyTFA() {
loading.show({
message: t('auth.signingIn')
})
try {
if (!/^[0-9]{6}$/.test(state.securityCode)) {
throw new Error(t('auth.errors.tfaMissing'))
}
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation(
$continuationToken: String!
$securityCode: String!
$strategyId: UUID!
$siteId: UUID!
) {
loginTFA(
continuationToken: $continuationToken
securityCode: $securityCode
strategyId: $strategyId
siteId: $siteId
) {
operation {
succeeded
message
}
jwt
nextAction
continuationToken
redirect
tfaQRImage
}
}
`,
variables: {
continuationToken: state.continuationToken,
securityCode: state.securityCode,
strategyId: state.selectedStrategyId,
siteId: siteStore.id
}
})
if (resp.data?.loginTFA?.operation?.succeeded) {
state.continuationToken = ''
state.securityCode = ''
await handleLoginResponse(resp.data.loginTFA)
} else {
throw new Error(resp.data?.loginTFA?.operation?.message || t('auth.errors.loginError'))
}
await handleLoginResponse(await submitTFA(false))
} catch (err) {
loading.hide()
notify({
type: 'negative',
message: err.message
})
await handleTFAError(err)
}
}
@ -890,60 +833,14 @@ async function finishSetupTFA() {
message: t('auth.tfaSetupVerifying')
})
try {
if (!/^[0-9]{6}$/.test(state.securityCode)) {
throw new Error(t('auth.errors.tfaMissing'))
}
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation(
$continuationToken: String!
$securityCode: String!
$strategyId: UUID!
$siteId: UUID!
) {
loginTFA(
continuationToken: $continuationToken
securityCode: $securityCode
strategyId: $strategyId
siteId: $siteId
setup: true
) {
operation {
succeeded
message
}
jwt
nextAction
continuationToken
redirect
tfaQRImage
}
}
`,
variables: {
continuationToken: state.continuationToken,
securityCode: state.securityCode,
strategyId: state.selectedStrategyId,
siteId: siteStore.id
}
})
if (resp.data?.loginTFA?.operation?.succeeded) {
state.continuationToken = ''
state.securityCode = ''
notify({
type: 'positive',
message: t('auth.tfaSetupSuccess')
})
await handleLoginResponse(resp.data.loginTFA)
} else {
throw new Error(resp.data?.loginTFA?.operation?.message || t('auth.errors.loginError'))
}
} catch (err) {
loading.hide()
const resp = await submitTFA(true)
notify({
type: 'negative',
message: err.message
type: 'positive',
message: t('auth.tfaSetupSuccess')
})
await handleLoginResponse(resp)
} catch (err) {
await handleTFAError(err)
}
}
@ -953,41 +850,3 @@ onMounted(async () => {
await fetchStrategies()
})
</script>
<style lang="scss">
.auth-login {
.otp-input {
width: 100%;
height: 48px;
padding: 5px;
margin: 0 5px;
font-size: 20px;
border-radius: 6px;
text-align: center;
@at-root .body--light & {
border: 2px solid rgba(0, 0, 0, 0.2);
}
@at-root .body--dark & {
border: 2px solid rgba(255, 255, 255, 0.3);
}
&:focus-visible {
outline-color: $primary;
}
/* Background colour of an input field with value */
&.is-complete {
border-color: $positive;
border-width: 2px;
}
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
}
</style>

@ -26,6 +26,7 @@
<blueprint-icon icon="password" />
<w-item-section>
<w-input
ref="newPasswordIpt"
v-model="state.newPassword"
outlined
dense
@ -90,15 +91,14 @@
<script setup>
import zxcvbn from 'zxcvbn'
import { sampleSize } from 'lodash-es'
import { sampleSize } from 'es-toolkit/array'
import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import { localizeError } from '@/helpers/localization'
import { computed, reactive, ref } from 'vue'
import { useSiteStore } from '@/stores/site'
// PROPS
const props = defineProps({
@ -116,10 +116,6 @@ defineEmits([...dialogComponentEmits])
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
// STORES
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
@ -136,6 +132,7 @@ const state = reactive({
// REFS
const changeUserPwdForm = ref(null)
const newPasswordIpt = ref(null)
// COMPUTED
@ -192,7 +189,9 @@ const verifyPasswordValidation = [
function randomizePassword() {
const pwdChars = 'abcdefghkmnpqrstuvwxyzABCDEFHJKLMNPQRSTUVWXYZ23456789_*=?#!()+'
state.newPassword = sampleSize(pwdChars, 16).join('')
state.newPassword = sampleSize([...pwdChars], 16).join('')
// -> A password the user never typed has to be readable, or there is no way to record it anywhere
newPasswordIpt.value.reveal()
}
async function save() {
@ -202,49 +201,29 @@ async function save() {
if (!isFormValid) {
throw new Error(t('auth.errors.fields'))
}
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation changePwd (
$currentPassword: String
$newPassword: String!
$strategyId: UUID!
$siteId: UUID!
) {
changePassword (
currentPassword: $currentPassword
newPassword: $newPassword
strategyId: $strategyId
siteId: $siteId
) {
operation {
succeeded
message
}
}
}
`,
variables: {
currentPassword: state.currentPassword,
newPassword: state.newPassword,
const resp = await API_CLIENT.put('users/profile/password', {
json: {
strategyId: props.strategyId,
siteId: siteStore.id
currentPassword: state.currentPassword,
newPassword: state.newPassword
}
})
if (resp?.data?.changePassword?.operation?.succeeded) {
notify({
type: 'positive',
message: t('auth.changePwd.success')
})
onDialogOK()
} else {
throw new Error(
resp?.data?.changePassword?.operation?.message || 'An unexpected error occured.'
)
}).json()
if (!resp?.ok) {
throw new Error(localizeError(resp?.message, t) || 'An unexpected error occured.')
}
notify({
type: 'positive',
message: t('auth.changePwd.success')
})
onDialogOK()
} catch (err) {
notify({
type: 'negative',
message: err.message
message:
(await err.response
?.json()
.then((b) => localizeError(b?.message, t))
.catch(() => null)) ?? err.message
})
}
state.isLoading = false

@ -65,9 +65,7 @@
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 lg:col-span-8">
<w-card class="shadow-1 pb-2">
<w-card-section>
<div class="text-subtitle1">{{ t('admin.groups.general') }}</div>
</w-card-section>
<w-card-header>{{ t('admin.groups.general') }}</w-card-header>
<w-item>
<blueprint-icon icon="team" />
<w-item-section>
@ -87,9 +85,7 @@
</w-item>
</w-card>
<w-card class="shadow-1 pb-2 mt-4" v-if="!isGuestGroup">
<w-card-section>
<div class="text-subtitle1">{{ t('admin.groups.authBehaviors') }}</div>
</w-card-section>
<w-card-header>{{ t('admin.groups.authBehaviors') }}</w-card-header>
<w-item>
<blueprint-icon icon="double-right" />
<w-item-section>
@ -138,9 +134,7 @@
</div>
<div class="col-span-12 lg:col-span-4">
<w-card class="shadow-1 pb-2">
<w-card-section>
<div class="text-subtitle1">{{ t('admin.groups.info') }}</div>
</w-card-section>
<w-card-header>{{ t('admin.groups.info') }}</w-card-header>
<w-item>
<blueprint-icon icon="team" :hue-rotate="-45" />
<w-item-section>
@ -398,11 +392,9 @@
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 lg:col-span-6">
<w-card class="shadow-1 pb-2">
<div class="flex justify-between">
<w-card-section>
<div class="text-subtitle1">{{ t(`admin.groups.permissions`) }}</div>
</w-card-section>
<w-card-section>
<w-card-header>
{{ t(`admin.groups.permissions`) }}
<template #action>
<w-btn
class="acrylic-btn"
icon="la:question-circle"
@ -411,8 +403,8 @@
type="a"
:href="siteStore.docsBase + `/admin/groups#permissions`"
target="_blank" />
</w-card-section>
</div>
</template>
</w-card-header>
<template v-for="(perm, idx) of permissions" :key="perm.permission">
<w-item tag="label">
<w-item-section class="items-center" style="flex: 0 0 40px;">

@ -145,7 +145,12 @@
</w-btn>
</template>
<w-space />
<template v-if="!(editorStore.isActive && editorStore.mode === `create`)">
<!--
Hidden outright while a suggestion is being written: duplicating, moving or deleting the page is
not part of suggesting a change to it, and a submitter who happens to hold those rights elsewhere
would otherwise find them here.
-->
<template v-if="!(editorStore.isActive && [`create`, `suggest`].includes(editorStore.mode))">
<w-btn
class="h-12"
v-if="userStore.can(`create:pages`)"
@ -177,7 +182,12 @@
<w-tooltip anchor="center left" self="center right">Delete Page</w-tooltip>
</w-btn>
</template>
<span class="page-actions-mode" v-else>{{ t('common.actions.newPage') }}</span>
<!-- What the rail says instead: which of the two write modes the editor is in. -->
<span class="page-actions-mode" v-else>{{
editorStore.mode === `suggest`
? t('common.actions.suggestedEdit')
: t('common.actions.newPage')
}}</span>
</div>
</template>

@ -4,7 +4,7 @@
<div class="flex-none pl-4 flex items-center">
<w-btn
class="rounded"
v-if="editorStore.isActive"
v-if="isEditing"
padding="none"
size="64px"
color="primary"
@ -36,7 +36,7 @@
<div class="min-w-0 flex-1 flex flex-col justify-center p-4">
<div class="text-h4 page-header-title">
<span
v-if="editorStore.isActive"
v-if="isEditing"
ref="titleEl"
class="page-header-editable"
:class="{ 'is-empty': !pageStore.title }"
@ -52,7 +52,7 @@
</div>
<div class="text-subtitle2 page-header-subtitle">
<span
v-if="editorStore.isActive"
v-if="isEditing"
ref="descriptionEl"
class="page-header-editable"
:class="{ 'is-empty': !pageStore.description }"
@ -151,6 +151,30 @@
no-caps
@click="editPage" />
</template>
<!--
For a reader who may read the page but not change it, and whose groups an approval rule lets
suggest edits to it. Same place and same shape as Edit, because it is the same intent -- what
differs is where the result goes.
-->
<template v-else-if="!editorStore.isActive && pageStore.canSuggestEdits">
<w-btn
class="acrylic-btn ml-4"
flat
icon="la:edit"
color="deep-orange-9"
:label="
pageStore.hasOpenSuggestion
? t(`common.actions.continueSuggestion`)
: t(`common.actions.suggestEdits`)
"
:aria-label="
pageStore.hasOpenSuggestion
? t(`common.actions.continueSuggestion`)
: t(`common.actions.suggestEdits`)
"
no-caps
@click="suggestEdits" />
</template>
<template v-if="editorStore.isActive || editorStore.hasPendingChanges">
<w-btn
class="acrylic-btn ml-2"
@ -167,7 +191,18 @@
@click="discardChanges" />
<w-btn
class="acrylic-btn ml-2"
v-if="editorStore.mode === `create`"
v-if="isSuggesting"
flat
icon="la:paper-plane"
color="positive"
:label="t(`common.actions.submitEdits`)"
:aria-label="t(`common.actions.submitEdits`)"
:disabled="!editorStore.hasPendingChanges"
no-caps
@click="submitSuggestion" />
<w-btn
class="acrylic-btn ml-2"
v-else-if="editorStore.mode === `create`"
flat
icon="la:check"
color="positive"
@ -240,9 +275,23 @@ const route = useRoute()
const { t } = useI18n()
// COMPUTED
/**
* Suggesting an edit rather than making one: the editor is open on a submission, and everything about
* the page other than its content is out of scope.
*/
const isSuggesting = computed(() => editorStore.isActive && editorStore.mode === 'suggest')
/**
* Editing the page itself, which is what makes the icon, title and description editable in place.
* Excludes suggest mode, where those are page properties the submitter has no say over.
*/
const isEditing = computed(() => editorStore.isActive && !isSuggesting.value)
// REFS
/** The two in-place fields, which only exist while the editor is open. */
/** The two in-place fields, which only exist while the page itself is being edited. */
const titleEl = ref(null)
const descriptionEl = ref(null)
@ -253,8 +302,8 @@ const descriptionEl = ref(null)
arriving with the editor already open, where the elements appear in the same tick as this runs.
*/
watch(
() => editorStore.isActive,
(isActive) => isActive && seedEditables(),
() => isEditing.value,
(editing) => editing && seedEditables(),
{ immediate: true }
)
@ -341,18 +390,24 @@ async function discardChanges() {
}
const hadPendingChanges = editorStore.hasPendingChanges
const wasSuggesting = isSuggesting.value
loading.show()
try {
editorStore.$patch({
isActive: false,
editor: ''
editor: '',
// -> Back to the ordinary meaning of the editor, or the next thing opened would inherit this one
mode: 'edit'
})
await pageStore.cancelPageEdit()
if (hadPendingChanges) {
notify({
type: 'positive',
message: 'Page has been reverted to the last saved state.'
// -> Nothing was reverted in the suggest case: the page never changed, the draft did
message: wasSuggesting
? t('common.page.suggestDiscarded')
: 'Page has been reverted to the last saved state.'
})
}
} catch (err) {
@ -491,6 +546,73 @@ async function editPage() {
loading.hide()
}
/**
* Open the editor on an edit suggestion. Picks up the reader's own pending suggestion if they have
* one, which the server decides -- see `pageSuggest`.
*/
async function suggestEdits() {
loading.show()
try {
await pageStore.pageSuggest()
} catch (err) {
notify({
type: 'negative',
message: t('common.page.suggestFailed'),
caption: err.message
})
}
loading.hide()
}
/**
* Send the suggestion, asking a guest who they are first: nothing else records that, and a reviewer
* has to be able to answer them.
*/
async function submitSuggestion() {
if (!userStore.authenticated) {
dialog({
component: defineAsyncComponent(() => import('../components/SuggestionGuestDialog.vue'))
}).onOk((guest) => submitSuggestionCommit(guest))
return
}
submitSuggestionCommit()
}
async function submitSuggestionCommit(guest = {}) {
loading.show()
try {
await pageStore.pageSubmitSuggestion(guest)
// -> Back to the page as everyone else sees it: what was typed is now a suggestion waiting for a
// reviewer, not a version of the page, so leaving the editor open on it would be a lie
editorStore.$patch({
isActive: false,
editor: '',
mode: 'edit'
})
await pageStore.pageLoad({ id: pageStore.id })
notify({
type: 'positive',
message: t('common.page.suggestSubmitted'),
// -> Only an account can be matched to a suggestion afterwards, so only a logged in author is
// told they can come back to it; for a guest that would be a promise nothing here can keep
caption: userStore.authenticated
? t('common.page.suggestSubmittedHint')
: t('common.page.suggestSubmittedHintGuest')
})
} catch (err) {
notify({
type: 'negative',
message: t('common.page.suggestSubmitFailed'),
caption:
(await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)) ?? err.message
})
}
loading.hide()
}
function printPage() {
window.print()
}

@ -1,6 +1,6 @@
<template>
<w-dialog v-model="dialogVisible" persistent @hide="onDialogHide">
<w-card class="setup2fadialog" style="min-width: 450px">
<w-card style="min-width: 450px">
<w-card-section class="card-header">
<w-icon name="img:/_assets/icons/fluent-fingerprint.svg" size="sm" class="mr-2" />
<span>{{ t(`profile.authSetTfa`) }}</span>
@ -18,7 +18,26 @@
<!-- eslint-disable-next-line vue/no-v-html -- server-generated QR code SVG -->
<div v-html="state.tfaQRImage" style="width: 200px" />
</div>
<p class="mt-2">{{ t('auth.tfaSetupInstrSecond') }}</p>
<!--
The same secret in text, for an authenticator app that is not on the device showing this,
or a user who would rather type it than point a camera at the screen. Grouped in fours to
be readable; the copy button copies it without the spaces.
-->
<div class="mt-2 text-caption text-grey">{{ t('auth.tfaSetupInstrManual') }}</div>
<div class="mt-1 flex items-center justify-center gap-2">
<code class="rounded bg-black/6 px-2 py-1 font-mono text-body2 dark:bg-white/10">{{
groupedSecret
}}</code>
<w-btn
class="acrylic-btn"
flat
dense
icon="la:copy"
:aria-label="t(`common.actions.copy`)"
color="primary"
@click="copySecret" />
</div>
<p class="mt-4">{{ t('auth.tfaSetupInstrSecond') }}</p>
<div class="flex flex-wrap justify-center">
<v-otp-input
v-model:value="state.securityCode"
@ -57,9 +76,9 @@ import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import { onMounted, reactive } from 'vue'
import { useSiteStore } from '@/stores/site'
import { copyToClipboard } from '@/helpers/clipboard'
import { localizeError } from '@/helpers/localization'
import { computed, onMounted, reactive } from 'vue'
import VOtpInput from 'vue3-otp-input'
@ -80,10 +99,6 @@ defineEmits([...dialogComponentEmits])
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
// STORES
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
@ -95,49 +110,66 @@ const state = reactive({
isLoading: false,
securityCode: '',
tfaQRImage: '',
tfaSecret: '',
continuationToken: ''
})
// COMPUTED
/** The secret in groups of four, which is how a 32-character string stays readable to type. */
const groupedSecret = computed(() => state.tfaSecret.replace(/.{4}(?=.)/g, '$& '))
// METHODS
/**
* The reason the API gave, out of a response ky threw on (anything above 400) or out of the error
* itself when the request never got an answer. An `ERR_*` code is translated on the way out.
*/
async function apiMessage(err) {
const message =
(await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)) ?? err.message
return localizeError(message, t)
}
async function copySecret() {
try {
// -> Without the display grouping: a space is harmless in most authenticator apps, but not all
await copyToClipboard(state.tfaSecret)
notify({
type: 'positive',
message: t('auth.tfaSetupKeyCopied')
})
} catch (err) {
notify({
type: 'negative',
message: t('auth.tfaSetupKeyCopyFailed'),
caption: err.message
})
}
}
async function load() {
state.isInit = false
try {
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation setupTfa (
$strategyId: UUID!
$siteId: UUID!
) {
setupTFA (
strategyId: $strategyId
siteId: $siteId
) {
operation {
succeeded
message
}
continuationToken
tfaQRImage
}
}
`,
variables: {
strategyId: props.strategyId,
siteId: siteStore.id
const resp = await API_CLIENT.post('users/profile/tfa', {
json: {
strategyId: props.strategyId
}
})
if (resp?.data?.setupTFA?.operation?.succeeded) {
state.continuationToken = resp.data.setupTFA.continuationToken
state.tfaQRImage = resp.data.setupTFA.tfaQRImage
state.isInit = true
} else {
throw new Error(resp?.data?.setupTFA?.operation?.message || 'An unexpected error occured.')
}).json()
if (!resp?.ok) {
throw new Error(localizeError(resp?.message, t) || 'An unexpected error occured.')
}
state.continuationToken = resp.continuationToken
state.tfaQRImage = resp.tfaQRImage
state.tfaSecret = resp.tfaSecret
state.isInit = true
} catch (err) {
notify({
type: 'negative',
message: err.message
message: await apiMessage(err)
})
onDialogCancel()
}
@ -149,51 +181,28 @@ async function save() {
if (!/^[0-9]{6}$/.test(state.securityCode)) {
throw new Error(t('auth.errors.tfaMissing'))
}
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation(
$continuationToken: String!
$securityCode: String!
$strategyId: UUID!
$siteId: UUID!
) {
loginTFA(
continuationToken: $continuationToken
securityCode: $securityCode
strategyId: $strategyId
siteId: $siteId
setup: true
) {
operation {
succeeded
message
}
}
}
`,
variables: {
continuationToken: state.continuationToken,
securityCode: state.securityCode,
const resp = await API_CLIENT.put('users/profile/tfa', {
json: {
strategyId: props.strategyId,
siteId: siteStore.id
continuationToken: state.continuationToken,
securityCode: state.securityCode
}
})
if (resp.data?.loginTFA?.operation?.succeeded) {
state.continuationToken = ''
state.securityCode = ''
notify({
type: 'positive',
message: t('auth.tfaSetupSuccess')
})
state.isLoading = false
onDialogOK()
} else {
throw new Error(resp.data?.loginTFA?.operation?.message || t('auth.errors.loginError'))
}).json()
if (!resp?.ok) {
throw new Error(localizeError(resp?.message, t) || t('auth.errors.loginError'))
}
state.continuationToken = ''
state.securityCode = ''
notify({
type: 'positive',
message: t('auth.tfaSetupSuccess')
})
state.isLoading = false
onDialogOK()
} catch (err) {
notify({
type: 'negative',
message: err.message
message: await apiMessage(err)
})
}
state.isLoading = false
@ -203,41 +212,3 @@ onMounted(() => {
load()
})
</script>
<style lang="scss">
.setup2fadialog {
.otp-input {
width: 100%;
height: 48px;
padding: 5px;
margin: 0 5px 7px;
font-size: 20px;
border-radius: 6px;
text-align: center;
@at-root .body--light & {
border: 2px solid rgba(0, 0, 0, 0.2);
}
@at-root .body--dark & {
border: 2px solid rgba(255, 255, 255, 0.3);
}
&:focus-visible {
outline-color: $primary;
}
/* Background colour of an input field with value */
&.is-complete {
border-color: $positive;
border-width: 2px;
}
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
}
</style>

@ -0,0 +1,121 @@
<template>
<w-dialog v-model="dialogVisible" @hide="onDialogHide">
<w-card style="min-width: 550px">
<w-card-section class="card-header">
<w-icon name="img:/_assets/icons/fluent-inspection.svg" size="sm" class="mr-2" />
<span>{{ t(`common.page.suggestIdentifyTitle`) }}</span>
</w-card-section>
<w-card-section>
<div class="text-body2">{{ t('common.page.suggestIdentifyHint') }}</div>
</w-card-section>
<w-form ref="guestForm" class="py-2" @submit="submit">
<w-item>
<blueprint-icon icon="contact" class="self-start" />
<w-item-section>
<w-input
ref="iptName"
v-model="state.name"
outlined
dense
:rules="nameValidation"
hide-bottom-space
:label="t(`common.page.suggestName`)"
autocomplete="name"
lazy-rules="ondemand"
autofocus />
</w-item-section>
</w-item>
<w-item>
<blueprint-icon icon="envelope" class="self-start" />
<w-item-section>
<w-input
v-model="state.email"
outlined
dense
type="email"
:rules="emailValidation"
hide-bottom-space
:label="t(`common.page.suggestEmail`)"
:hint="t(`common.page.suggestEmailHint`)"
autocomplete="email"
lazy-rules="ondemand" />
</w-item-section>
</w-item>
</w-form>
<w-card-actions class="card-actions">
<w-space />
<w-btn
class="acrylic-btn"
flat
:label="t(`common.actions.cancel`)"
color="grey"
padding="xs md"
@click="onDialogCancel" />
<w-btn
unelevated
:label="t(`common.actions.submitEdits`)"
color="positive"
padding="xs md"
@click="submit" />
</w-card-actions>
</w-card>
</w-dialog>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { reactive, ref } from 'vue'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
/**
* Who is suggesting this edit, asked of a reader with no account.
*
* A logged in author is already known, so this never opens for one. For everybody else it is the only
* record of where the suggestion came from, which is what lets a reviewer answer them.
*/
// EMITS
defineEmits([...dialogComponentEmits])
// DIALOG
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent({
autofocus: () => iptName.value
})
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
name: '',
email: ''
})
// REFS
const guestForm = ref(null)
const iptName = ref(null)
// VALIDATION RULES
const nameValidation = [(val) => (val ?? '').trim().length > 0 || t('auth.errors.missingName')]
const emailValidation = [
(val) => (val ?? '').trim().length > 0 || t('auth.errors.missingEmail'),
(val) => /^.+@.+\..+$/.test(val) || t('auth.errors.invalidEmail')
]
// METHODS
async function submit() {
if (!(await guestForm.value.validate(true))) {
return
}
onDialogOK({ guestName: state.name.trim(), guestEmail: state.email.trim() })
}
</script>

@ -68,9 +68,7 @@
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 lg:col-span-8">
<w-card class="shadow-1 pb-2">
<w-card-section>
<div class="text-subtitle1">{{ t('admin.users.profile') }}</div>
</w-card-section>
<w-card-header>{{ t('admin.users.profile') }}</w-card-header>
<w-item>
<blueprint-icon icon="contact" />
<w-item-section>
@ -153,9 +151,7 @@
</template>
</w-card>
<w-card class="shadow-1 pb-2 mt-4" v-if="state.user.meta">
<w-card-section>
<div class="text-subtitle1">{{ t('admin.users.preferences') }}</div>
</w-card-section>
<w-card-header>{{ t('admin.users.preferences') }}</w-card-header>
<w-item>
<blueprint-icon icon="timezone" />
<w-item-section>
@ -268,9 +264,7 @@
</div>
<div class="col-span-12 lg:col-span-4">
<w-card class="shadow-1 pb-2">
<w-card-section>
<div class="text-subtitle1">{{ t('admin.users.info') }}</div>
</w-card-section>
<w-card-header>{{ t('admin.users.info') }}</w-card-header>
<w-item>
<blueprint-icon icon="person" :hue-rotate="-45" />
<w-item-section>
@ -312,10 +306,9 @@
</w-item>
</w-card>
<w-card class="shadow-1 pb-2 mt-4" v-if="state.user.meta">
<w-card-section>
<div class="text-subtitle1">{{ t('admin.users.notes') }}</div>
<w-card-header>{{ t('admin.users.notes') }}</w-card-header>
<w-card-section class="pt-0">
<w-input
class="mt-2"
outlined
v-model="state.user.meta.notes"
type="textarea"
@ -334,9 +327,7 @@
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 lg:col-span-7">
<w-card class="shadow-1 pb-2">
<w-card-section>
<div class="text-subtitle1">{{ t('admin.users.passAuth') }}</div>
</w-card-section>
<w-card-header>{{ t('admin.users.passAuth') }}</w-card-header>
<w-item>
<blueprint-icon icon="password" :hue-rotate="45" />
<w-item-section>
@ -397,9 +388,7 @@
</w-item>
</w-card>
<w-card class="shadow-1 pb-2 mt-4">
<w-card-section>
<div class="text-subtitle1">{{ t('admin.users.tfa') }}</div>
</w-card-section>
<w-card-header>{{ t('admin.users.tfa') }}</w-card-header>
<w-item tag="label">
<blueprint-icon icon="key" />
<w-item-section>
@ -441,11 +430,9 @@
</div>
<div class="col-span-12 lg:col-span-5">
<w-card class="shadow-1 pb-2">
<w-card-section>
<div class="text-subtitle1">{{ t('admin.users.linkedProviders') }}</div>
<w-card-header>{{ t('admin.users.linkedProviders') }}</w-card-header>
<w-card-section v-if="linkedAuthProviders.length < 1" class="pt-0">
<w-banner
class="mt-4"
v-if="linkedAuthProviders.length < 1"
rounded
:class="dark.isActive ? `bg-negative text-white` : `bg-grey-2 text-grey-7`"
>{{ t('admin.users.noLinkedProviders') }}</w-banner
@ -471,9 +458,7 @@
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 lg:col-span-8">
<w-card class="shadow-1 pb-2">
<w-card-section>
<div class="text-subtitle1">{{ t('admin.users.groups') }}</div>
</w-card-section>
<w-card-header>{{ t('admin.users.groups') }}</w-card-header>
<template v-for="(grp, idx) of state.user.groups" :key="grp.id">
<w-separator class="my-2" inset v-if="idx > 0" />
<w-item>
@ -535,15 +520,16 @@
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 lg:col-span-8">
<w-card class="shadow-1 pb-2">
<w-card-section class="flex items-center">
<div class="text-subtitle1">{{ t('admin.users.metadata') }}</div>
<w-space />
<w-badge v-if="state.metadataInvalidJSON" color="negative">
<w-icon class="mr-1" name="la:exclamation-triangle" size="20px" />
<span>{{ t('admin.users.invalidJSON') }}</span>
</w-badge>
<w-badge class="py-1" v-else label="JSON" color="positive" />
</w-card-section>
<w-card-header>
{{ t('admin.users.metadata') }}
<template #action>
<w-badge v-if="state.metadataInvalidJSON" color="negative">
<w-icon class="mr-1" name="la:exclamation-triangle" size="20px" />
<span>{{ t('admin.users.invalidJSON') }}</span>
</w-badge>
<w-badge class="py-1" v-else label="JSON" color="positive" />
</template>
</w-card-header>
<w-item>
<w-item-section>
<util-code-editor
@ -563,9 +549,7 @@
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 lg:col-span-8">
<w-card class="shadow-1 pb-2">
<w-card-section>
<div class="text-subtitle1">{{ t('admin.users.operations') }}</div>
</w-card-section>
<w-card-header>{{ t('admin.users.operations') }}</w-card-header>
<w-item>
<blueprint-icon icon="email-open" :hue-rotate="45" />
<w-item-section>

@ -506,6 +506,16 @@ registerWithForm?.({ validate })
defineExpose({
validate,
focus: () => inputEl.value?.focus(),
/**
* Show the value of a `revealable` password field, as if the eye had been clicked.
*
* For a caller that fills the field in itself: a generated password the user never typed is worth
* nothing hidden behind dots, and having to click the eye afterwards is a step with no purpose.
* Hiding it again is left to the user, which is why there is no matching `conceal()`.
*/
reveal: () => {
isRevealed.value = true
},
hasError
})
</script>

@ -29,6 +29,19 @@ let seq = 0
* @returns {{ onOk: Function, onCancel: Function, onDismiss: Function }} Chainable handle.
*/
export function dialog({ component, componentProps = {} }) {
/*
Loudly, because the failure is otherwise invisible: a `dialog({ title, message })` call -- the form
the replaced library supported -- mounts nothing, so `.onOk()` never fires and the button that
opened it appears to do nothing at all. That is exactly how three dead confirmations sat unnoticed
on the profile authentication page. `confirm()` is the form that takes a title and a message.
*/
if (!component) {
console.error(
'dialog() requires a component. For a title/message confirmation, call confirm() instead.',
componentProps
)
}
const id = ++seq
const handlers = { ok: [], cancel: [], dismiss: [] }

@ -722,6 +722,66 @@
line-height: inherit;
text-align: inherit;
}
/*
The digit fields of a security code, as `vue3-otp-input` renders them -- it ships no stylesheet of
its own and takes the class name to put on each input. Kept here rather than in each of the two
components that use it, since a code entry should look the same on the login screen and in the
setup dialog.
The size has to be stated: the library lays the inputs out in a flex row, so a field with no width
of its own is stretched to whatever share of the row it lands in -- which in a dialog several
hundred pixels wide is a very wide box for one digit.
*/
.otp-input {
width: 3rem;
height: 3rem;
margin: 0 0.25rem;
padding: 0;
border: 2px solid rgba(0, 0, 0, 0.2);
border-radius: 6px;
font-size: 1.25rem;
text-align: center;
}
/*
The library puts `display: flex` on the row itself; centering and wrapping are left to whoever
styles it. Both matter on a narrow phone, where six fields inside the login screen's padding would
otherwise run past the edge.
*/
.otp-input-container {
flex-wrap: wrap;
justify-content: center;
}
@media (max-width: 420px) {
.otp-input {
width: 2.25rem;
height: 2.25rem;
margin: 0 0.125rem;
font-size: 1rem;
}
}
body.body--dark .otp-input {
border-color: rgba(255, 255, 255, 0.3);
}
.otp-input:focus-visible {
outline-color: var(--color-primary);
}
/* Filled in, i.e. this digit is entered -- the library adds the class. */
.otp-input.is-complete {
border-color: var(--color-positive);
}
/* A digit field is not a number picker, whatever `input-type="number"` implies. */
.otp-input::-webkit-inner-spin-button,
.otp-input::-webkit-outer-spin-button {
appearance: none;
margin: 0;
}
}
/*

@ -127,6 +127,14 @@
</w-item-section>
<w-item-section>{{ t('admin.general.title') }}</w-item-section>
</w-item>
<w-item
:to="`/_admin/` + adminStore.currentSiteId + `/approvals`"
active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-inspection.svg" />
</w-item-section>
<w-item-section>{{ t('admin.approval.title') }}</w-item-section>
</w-item>
<template v-if="flagsStore.experimental">
<w-item
:to="`/_admin/` + adminStore.currentSiteId + `/analytics`"
@ -137,15 +145,6 @@
</w-item-section>
<w-item-section>{{ t('admin.analytics.title') }}</w-item-section>
</w-item>
<w-item
:to="`/_admin/` + adminStore.currentSiteId + `/approvals`"
active-class="bg-primary text-white"
disabled>
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-inspection.svg" />
</w-item-section>
<w-item-section>{{ t('admin.approval.title') }}</w-item-section>
</w-item>
<w-item
:to="`/_admin/` + adminStore.currentSiteId + `/comments`"
active-class="bg-primary text-white"

@ -0,0 +1,318 @@
<template>
<w-page>
<div class="flex flex-wrap items-center p-4">
<div class="flex-none">
<img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-inspection.svg" />
</div>
<div class="min-w-0 flex-1 pl-4">
<div class="text-h5 text-primary animated fadeInLeft">{{ t('admin.approval.title') }}</div>
<div class="text-subtitle1 text-grey animated fadeInLeft wait-p2s">
{{ t('admin.approval.subtitle') }}
</div>
</div>
<div class="flex flex-none">
<w-btn
class="mr-2 acrylic-btn"
icon="la:question-circle"
flat
color="grey"
:aria-label="t(`common.actions.viewDocs`)"
:href="siteStore.docsBase + `/admin/approvals`"
target="_blank">
<w-tooltip>{{ t(`common.actions.viewDocs`) }}</w-tooltip>
</w-btn>
<w-btn
class="acrylic-btn mr-2"
icon="la:redo-alt"
flat
color="secondary"
:loading="state.loading > 0"
:aria-label="t(`common.actions.refresh`)"
@click="load">
<w-tooltip>{{ t(`common.actions.refresh`) }}</w-tooltip>
</w-btn>
<w-btn
unelevated
icon="la:plus"
:label="t(`admin.approval.newRule`)"
color="primary"
@click="createRule" />
</div>
</div>
<w-separator inset />
<div class="p-4">
<!--
An empty list is the normal starting state rather than an error, and it is worth saying what
it means: with no rule matching a page, that page takes no suggestions at all.
-->
<w-banner
v-if="state.rules.length < 1 && state.loading < 1"
rounded
:class="dark.isActive ? `bg-dark-3 text-grey-4` : `bg-grey-2 text-grey-8`">
{{ t('admin.approval.noRules') }}
</w-banner>
<w-card v-else>
<w-list separator>
<w-item v-for="rule of state.rules" :key="rule.id">
<blueprint-icon icon="rules" />
<!--
A disabled rule keeps everything it says but covers nothing, so it is dimmed rather than
hidden or moved: it is still part of the configuration being read.
-->
<w-item-section :class="rule.isEnabled ? `` : `opacity-60`">
<w-item-label>
<strong>{{ rule.name }}</strong>
</w-item-label>
<w-item-label caption>
{{ matchLabel(rule.match) }}
<span class="font-mono">{{ patternLabel(rule) }}</span>
</w-item-label>
<w-item-label caption>
<span class="text-grey">{{ t('admin.approval.submitters') }}:</span>
{{ groupNames(rule.submitterGroups) }}
</w-item-label>
<w-item-label caption>
<span class="text-grey">{{ t('admin.approval.reviewers') }}:</span>
{{ groupNames(rule.reviewerGroups) }}
</w-item-label>
</w-item-section>
<w-item-section side>
<w-toggle
:model-value="rule.isEnabled"
:label="t(`admin.approval.enabled`)"
:aria-label="t(`admin.approval.enabled`)"
@update:model-value="
(val) => {
setEnabled(rule, val)
}
" />
</w-item-section>
<w-separator class="ml-4" vertical />
<w-item-section side style="flex-direction: row; align-items: center">
<w-btn
class="acrylic-btn mr-2"
flat
@click="editRule(rule)"
icon="la:pen"
:color="dark.isActive ? `indigo-4` : `indigo`"
:label="t(`common.actions.edit`)"
no-caps />
<w-btn
class="acrylic-btn"
flat
icon="la:trash"
color="negative"
@click="deleteRule(rule)"
:aria-label="t(`common.actions.delete`)" />
</w-item-section>
</w-item>
</w-list>
</w-card>
</div>
<w-inner-loading :showing="state.loading > 0" />
</w-page>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { onMounted, reactive, watch } from 'vue'
import { useDark } from '@/composables/dark'
import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { confirm, dialog } from '@/composables/dialog'
import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site'
import ApprovalRuleDialog from '@/components/ApprovalRuleDialog.vue'
// COMPOSABLES
const dark = useDark()
// STORES
const adminStore = useAdminStore()
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// META
useMeta({
title: t('admin.approval.title')
})
// DATA
const state = reactive({
loading: 0,
rules: [],
groups: []
})
// WATCHERS
watch(() => adminStore.currentSiteId, load)
// METHODS
/** The reason the API gave, out of a response ky threw on, or the error's own message. */
async function apiMessage(err) {
return (
(await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)) ?? err.message
)
}
function matchLabel(match) {
return (
{
START: t('admin.approval.matchStart'),
EXACT: t('admin.approval.matchExact'),
END: t('admin.approval.matchEnd'),
REGEX: t('admin.approval.matchRegex'),
TAG: t('admin.approval.matchTag'),
TAGALL: t('admin.approval.matchTagAll')
}[match] ?? match
)
}
/** The pattern as it is written in the rule: a path with its slash, or a plain list of tags. */
function patternLabel(rule) {
if (['TAG', 'TAGALL'].includes(rule.match)) {
return rule.path
}
return rule.match === 'REGEX' ? `/${rule.path}/` : `/${rule.path}`
}
/**
* Group names for a list of IDs.
*
* An ID with no group left to name is shown as-is rather than dropped: a rule pointing at a deleted
* group grants nothing, and hiding that would make the row look correct.
*/
function groupNames(groupIds) {
return (groupIds ?? []).map((id) => state.groups.find((g) => g.id === id)?.name ?? id).join(', ')
}
async function load() {
if (!adminStore.currentSiteId) {
return
}
state.loading++
try {
// -> The groups are what turn the stored IDs into names, so both are needed before the list means
// anything; fetched together rather than in sequence
const [rules, groups] = await Promise.all([
API_CLIENT.get(`sites/${adminStore.currentSiteId}/approvals/rules`).json(),
API_CLIENT.get('groups').json()
])
state.rules = rules ?? []
state.groups = groups ?? []
} catch (err) {
notify({
type: 'negative',
message: t('admin.approval.loadFailed'),
caption: await apiMessage(err)
})
}
state.loading--
}
/**
* Turn a rule on or off, saved as soon as the switch moves.
*
* The row is updated from the response rather than optimistically: a refused change has to leave the
* switch showing what the server actually holds.
*/
async function setEnabled(rule, isEnabled) {
state.loading++
try {
const resp = await API_CLIENT.put(
`sites/${adminStore.currentSiteId}/approvals/rules/${rule.id}`,
{ json: { isEnabled } }
).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
Object.assign(rule, resp.rule)
notify({
type: 'positive',
message: isEnabled ? t('admin.approval.enableSuccess') : t('admin.approval.disableSuccess')
})
} catch (err) {
notify({
type: 'negative',
message: t('admin.approval.saveFailed'),
caption: await apiMessage(err)
})
await load()
}
state.loading--
}
function createRule() {
dialog({
component: ApprovalRuleDialog,
componentProps: {
siteId: adminStore.currentSiteId,
groups: state.groups
}
}).onOk(load)
}
function editRule(rule) {
dialog({
component: ApprovalRuleDialog,
componentProps: {
siteId: adminStore.currentSiteId,
groups: state.groups,
rule
}
}).onOk(load)
}
function deleteRule(rule) {
confirm({
title: t('admin.approval.deleteRule'),
message: t('admin.approval.deleteRuleConfirm', {
pattern: `${matchLabel(rule.match)} ${patternLabel(rule)}`
}),
cancel: true,
color: 'negative',
okLabel: t('common.actions.delete')
}).onOk(async () => {
state.loading++
try {
const resp = await API_CLIENT.delete(
`sites/${adminStore.currentSiteId}/approvals/rules/${rule.id}`
)
if (!resp?.ok) {
throw new Error((await resp.json())?.message || 'An unexpected error occured.')
}
notify({
type: 'positive',
message: t('admin.approval.deleteSuccess')
})
} catch (err) {
notify({
type: 'negative',
message: t('admin.approval.deleteFailed'),
caption: await apiMessage(err)
})
}
state.loading--
await load()
})
}
// MOUNTED
onMounted(load)
</script>

@ -12,34 +12,104 @@
</w-item-section>
<w-item-section>
<strong>{{ auth.authName }}</strong>
<div v-if="!auth.config.isPasswordLoginEnabled" class="text-caption text-negative">
{{ t('profile.authPasswordLoginOff') }}
</div>
<!--
A disabled button with no reason next to it reads as a bug. This is the reason: the
server refuses to turn password login off while it is the only way into the account.
-->
<div
v-else-if="auth.strategyKey === `local` && !auth.config.canDisablePasswordLogin"
class="text-caption text-grey">
{{ t('profile.authPasswordLoginOnlyMethod') }}
</div>
</w-item-section>
<template v-if="auth.strategyKey === `local`">
<w-item-section v-if="auth.config.isTfaSetup" side>
<!--
One trigger rather than a row of buttons: these are occasional actions on a row that also
has to stay readable, and a `w-item` puts every `side` section on the same line.
-->
<w-item-section v-if="auth.strategyKey === `local`" side>
<div class="flex items-center gap-3">
<!--
Says at a glance that the account is protected, without opening the menu to find out.
Only shown when 2FA is on: the absence of a badge is not a warning, since 2FA is
optional unless an administrator requires it.
-->
<w-badge
v-if="auth.config.isTfaSetup"
class="gap-1"
color="positive"
rounded
:title="t('profile.authTfaActive')">
<w-icon name="la:check" />
<span>{{ t('profile.authTfaBadge') }}</span>
</w-badge>
<w-btn
icon="la:fingerprint"
unelevated
:label="t(`profile.authDisableTfa`)"
color="negative"
:disable="auth.config.isTfaRequired"
@click="disableTfa(auth.authId)" />
</w-item-section>
<w-item-section v-else side>
<w-btn
icon="la:fingerprint"
unelevated
:label="t(`profile.authSetTfa`)"
color="primary"
@click="setupTfa(auth.authId)" />
</w-item-section>
<w-item-section side>
<w-btn
icon="la:key"
unelevated
:label="t(`profile.authChangePassword`)"
class="acrylic-btn"
flat
dense
round
icon="la:cog"
color="primary"
@click="changePassword(auth.authId)" />
</w-item-section>
</template>
:aria-label="t(`profile.authActions`)">
<w-menu class="translucent-menu" auto-close anchor="bottom right" self="top right">
<!--
`!min-w-0 !pr-2` on each icon section: an avatar section is a 56px column with 16px
of padding after it, which is the right metric for a 40px avatar in a list row and
far too much air beside a 24px icon in a menu. Both rules are scoped styles in
WItemSection, hence `!` -- a layered utility cannot outrank them.
The colours are literal classes rather than WIcon's `color` prop: that prop builds
`text-${color}` at runtime, and Tailwind only emits a utility it can see spelled out
in the source, so `color="blue-7"` would compile to a class that does not exist.
-->
<w-list dense padding style="min-width: 240px">
<w-item clickable @click="changePassword(auth.authId)">
<w-item-section avatar class="!min-w-0 !pr-2">
<w-icon name="la:key" class="text-blue-7" />
</w-item-section>
<w-item-section>{{ t('profile.authChangePassword') }}</w-item-section>
</w-item>
<w-item
v-if="auth.config.isTfaSetup"
clickable
@click="disableTfa(auth.authId)">
<w-item-section avatar class="!min-w-0 !pr-2">
<w-icon name="la:fingerprint" class="text-blue-7" />
</w-item-section>
<w-item-section>{{ t('profile.authDisableTfa') }}</w-item-section>
</w-item>
<w-item v-else clickable @click="setupTfa(auth.authId)">
<w-item-section avatar class="!min-w-0 !pr-2">
<w-icon name="la:fingerprint" class="text-blue-7" />
</w-item-section>
<w-item-section>{{ t('profile.authSetTfa') }}</w-item-section>
</w-item>
<w-separator class="my-2" />
<w-item
v-if="auth.config.isPasswordLoginEnabled"
clickable
:disabled="!auth.config.canDisablePasswordLogin"
@click="disablePasswordLogin(auth.authId)">
<w-item-section avatar class="!min-w-0 !pr-2">
<w-icon name="la:ban" class="text-negative" />
</w-item-section>
<w-item-section class="text-negative">
{{ t('profile.authDisablePasswordLogin') }}
</w-item-section>
</w-item>
<w-item v-else clickable @click="enablePasswordLogin(auth.authId)">
<w-item-section avatar class="!min-w-0 !pr-2">
<w-icon name="la:redo" class="text-blue-7" />
</w-item-section>
<w-item-section>{{ t('profile.authEnablePasswordLogin') }}</w-item-section>
</w-item>
</w-list>
</w-menu>
</w-btn>
</div>
</w-item-section>
</w-item>
</w-list>
</div>
@ -90,24 +160,15 @@ import { useI18n } from 'vue-i18n'
import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading'
import { dialog } from '@/composables/dialog'
import { confirm, dialog } from '@/composables/dialog'
import { onMounted, reactive } from 'vue'
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser'
import { localizeError } from '@/helpers/localization'
import { DateTime } from 'luxon'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
import ChangePwdDialog from '@/components/ChangePwdDialog.vue'
import SetupTfaDialog from '@/components/SetupTfaDialog.vue'
import PasskeyCreateDialog from '@/components/PasskeyCreateDialog.vue'
// STORES
const siteStore = useSiteStore()
const userStore = useUserStore()
// I18N
const { t } = useI18n()
@ -128,50 +189,37 @@ const state = reactive({
// METHODS
/**
* The reason the API gave, out of a response ky threw on (anything above 400) or out of the error
* itself when the request never got an answer. An `ERR_*` code is translated on the way out.
*/
async function apiMessage(err) {
const message =
(await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)) ?? err.message
return localizeError(message, t)
}
function humanizeDate(val) {
return DateTime.fromISO(val).toLocaleString(DateTime.DATETIME_MED)
return Temporal.Instant.from(val).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
})
}
async function fetchAuthMethods() {
state.loading++
try {
const respRaw = await APOLLO_CLIENT.query({
query: `
query getUserProfileAuthMethods (
$id: UUID!
) {
userById (
id: $id
) {
id
auth {
authId
authName
strategyKey
strategyIcon
config
}
passkeys {
id
name
createdAt
siteHostname
}
}
}
`,
variables: {
id: userStore.id
},
fetchPolicy: 'network-only'
})
state.authMethods = respRaw.data?.userById?.auth ?? []
state.passkeys = respRaw.data?.userById?.passkeys ?? []
const resp = await API_CLIENT.get('users/profile/auth').json()
state.authMethods = resp?.authMethods ?? []
state.passkeys = resp?.passkeys ?? []
} catch (err) {
notify({
type: 'negative',
message: t('profile.authLoadingFailed'),
caption: err.message
caption: await apiMessage(err)
})
}
state.loading--
@ -187,45 +235,29 @@ function changePassword(strategyId) {
}
function disableTfa(strategyId) {
dialog({
confirm({
title: t('common.actions.confirm'),
message: t('profile.authDisableTfaConfirm'),
cancel: true
cancel: true,
color: 'negative',
okLabel: t('profile.authDisableTfa')
}).onOk(async () => {
loading.show()
try {
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation deactivateTfa (
$strategyId: UUID!
) {
deactivateTFA(
strategyId: $strategyId
) {
operation {
succeeded
message
}
}
}
`,
variables: {
strategyId
}
})
if (resp?.data?.deactivateTFA?.operation?.succeeded) {
notify({
type: 'positive',
message: t('profile.authDisableTfaSuccess')
})
} else {
throw new Error(resp?.data?.deactivateTFA?.operation?.message)
// -> Answers 204, so there is no body to read only whether it succeeded
const resp = await API_CLIENT.delete(`users/profile/tfa/${strategyId}`)
if (!resp?.ok) {
throw new Error(localizeError((await resp.json())?.message, t))
}
notify({
type: 'positive',
message: t('profile.authDisableTfaSuccess')
})
} catch (err) {
notify({
type: 'negative',
message: t('profile.authDisableTfaFailed'),
caption: err.message ?? 'An unexpected error occured.'
caption: await apiMessage(err)
})
}
await fetchAuthMethods()
@ -233,6 +265,51 @@ function disableTfa(strategyId) {
})
}
function disablePasswordLogin(strategyId) {
confirm({
title: t('common.actions.confirm'),
message: t('profile.authDisablePasswordLoginConfirm'),
cancel: true,
color: 'negative',
okLabel: t('profile.authDisablePasswordLogin')
}).onOk(() => setPasswordLogin(strategyId, false))
}
function enablePasswordLogin(strategyId) {
setPasswordLogin(strategyId, true)
}
async function setPasswordLogin(strategyId, isEnabled) {
loading.show()
try {
const resp = await API_CLIENT.put('users/profile/password-login', {
json: {
strategyId,
isEnabled
}
}).json()
if (!resp?.ok) {
throw new Error(localizeError(resp?.message, t))
}
notify({
type: 'positive',
message: isEnabled
? t('profile.authEnablePasswordLoginSuccess')
: t('profile.authDisablePasswordLoginSuccess')
})
} catch (err) {
notify({
type: 'negative',
message: isEnabled
? t('profile.authEnablePasswordLoginFailed')
: t('profile.authDisablePasswordLoginFailed'),
caption: await apiMessage(err)
})
}
await fetchAuthMethods()
loading.hide()
}
function setupTfa(strategyId) {
dialog({
component: SetupTfaDialog,
@ -251,39 +328,18 @@ async function setupPasskey() {
}
loading.show()
// -> Generation registration options
const genResp = await APOLLO_CLIENT.mutate({
mutation: `
mutation setupPasskey (
$siteId: UUID!
) {
setupPasskey(
siteId: $siteId
) {
operation {
succeeded
message
}
registrationOptions
}
}
`,
variables: {
siteId: siteStore.id
}
})
if (genResp?.data?.setupPasskey?.operation?.succeeded) {
state.registrationOptions = genResp.data.setupPasskey.registrationOptions
} else {
throw new Error(localizeError(genResp?.data?.setupPasskey?.operation?.message, t))
// -> Generate registration options
const genResp = await API_CLIENT.post('users/profile/passkeys/challenge').json()
if (!genResp?.ok) {
throw new Error(localizeError(genResp?.message, t))
}
// -> Start registration on the authenticator
let attResp
try {
attResp = await startRegistration(state.registrationOptions)
attResp = await startRegistration({ optionsJSON: genResp.registrationOptions })
} catch (err) {
if (err.name === 'InvalidStateError') {
throw new Error(t('error.ERR_PK_ALREADY_REGISTERED'))
@ -310,41 +366,24 @@ async function setupPasskey() {
// -> Verify the authenticator response
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation finalizePasskey (
$registrationResponse: JSON!
$name: String!
) {
finalizePasskey(
registrationResponse: $registrationResponse
name: $name
) {
operation {
succeeded
message
}
}
}
`,
variables: {
registrationResponse: attResp,
name: passkeyName
const resp = await API_CLIENT.post('users/profile/passkeys', {
json: {
name: passkeyName,
registrationResponse: attResp
}
})
if (resp?.data?.finalizePasskey?.operation?.succeeded) {
notify({
type: 'positive',
message: t('profile.passkeysSetupSuccess')
})
} else {
throw new Error(resp?.data?.finalizePasskey?.operation?.message)
}).json()
if (!resp?.ok) {
throw new Error(localizeError(resp?.message, t))
}
notify({
type: 'positive',
message: t('profile.passkeysSetupSuccess')
})
} catch (err) {
notify({
type: 'negative',
message: t('profile.passkeysSetupFailed'),
caption: err.message ?? 'An unexpected error occured.'
caption: await apiMessage(err)
})
}
await fetchAuthMethods()
@ -352,45 +391,28 @@ async function setupPasskey() {
}
async function deactivatePasskey(pkey) {
dialog({
confirm({
title: t('common.actions.confirm'),
message: t('profile.passkeysDeactivateConfirm'),
cancel: true
cancel: true,
color: 'negative',
okLabel: t('common.actions.delete')
}).onOk(async () => {
loading.show()
try {
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation deactivatePasskey (
$id: UUID!
) {
deactivatePasskey(
id: $id
) {
operation {
succeeded
message
}
}
}
`,
variables: {
id: pkey.id
}
})
if (resp?.data?.deactivatePasskey?.operation?.succeeded) {
notify({
type: 'positive',
message: t('profile.passkeysDeactivateSuccess')
})
} else {
throw new Error(resp?.data?.deactivatePasskey?.operation?.message)
const resp = await API_CLIENT.delete(`users/profile/passkeys/${encodeURIComponent(pkey.id)}`)
if (!resp?.ok) {
throw new Error(localizeError((await resp.json())?.message, t))
}
notify({
type: 'positive',
message: t('profile.passkeysDeactivateSuccess')
})
} catch (err) {
notify({
type: 'negative',
message: t('profile.passkeysDeactivateFailed'),
caption: err.message ?? 'An unexpected error occured.'
caption: await apiMessage(err)
})
}
await fetchAuthMethods()

@ -43,6 +43,7 @@ const routes = [
{ path: 'sites', component: () => import('@/pages/AdminSites.vue') },
// -> Site
{ path: ':siteid/general', component: () => import('@/pages/AdminGeneral.vue') },
{ path: ':siteid/approvals', component: () => import('@/pages/AdminApprovals.vue') },
{ path: ':siteid/blocks', component: () => import('@/pages/AdminBlocks.vue') },
{ path: ':siteid/editors', component: () => import('@/pages/AdminEditors.vue') },
{ path: ':siteid/locale', component: () => import('@/pages/AdminLocale.vue') },

@ -4,6 +4,7 @@ import { pick } from 'es-toolkit/object'
import { useSiteStore } from './site'
import { useEditorStore } from './editor'
import { useUserStore } from './user'
/**
* The icon a page starts with.
@ -68,7 +69,16 @@ export const usePageStore = defineStore('page', {
min: 1,
max: 2
},
updatedAt: ''
updatedAt: '',
/**
* Whether this reader may suggest edits to this page, i.e. an enabled approval rule covers it and
* names a group they are in. Answered by the server, since neither the rules nor the reader's
* groups are known here and left false until it does, so the button never flashes into view on
* a page that turns out not to take suggestions.
*/
canSuggestEdits: false,
/** Whether the reader already has a suggestion open on this page, which they would carry on with. */
hasOpenSuggestion: false
}),
getters: {
breadcrumbs: (state) => {
@ -137,6 +147,18 @@ export const usePageStore = defineStore('page', {
lastChangeTimestamp: curDate,
lastSaveTimestamp: curDate
})
/*
Whether this reader may suggest edits, which is only a question for one who cannot make them
directly -- anybody who can edit the page just edits it. Not awaited: it decides whether one
button appears, and the page has no reason to wait for that.
*/
const userStore = useUserStore()
if (userStore.can('edit:pages')) {
this.$patch({ canSuggestEdits: false, hasOpenSuggestion: false })
} else {
this.fetchSuggestState()
}
} catch (err) {
// -> A missing page is an ordinary outcome, not a failure: it is what puts a new instance in
// front of the welcome screen, and what offers to create the page anywhere else
@ -293,6 +315,92 @@ export const usePageStore = defineStore('page', {
throw err
}
},
/**
* PAGE - SUGGESTION STATE
*
* Whether this page takes edit suggestions from whoever is reading it. Only worth asking for a
* reader who cannot edit the page outright anyone who can just edits it so the caller decides
* when to ask, and a page nobody may suggest against simply leaves the flags false.
*/
async fetchSuggestState() {
const siteStore = useSiteStore()
if (!this.id) {
return
}
try {
const resp = await API_CLIENT.get(
`sites/${siteStore.id}/pages/${this.id}/suggestions/self`
).json()
this.$patch({
canSuggestEdits: Boolean(resp?.canSubmit),
hasOpenSuggestion: Boolean(resp?.submission)
})
} catch (err) {
// -> Not being able to answer is not the same as being told no, but it comes to the same thing
// on screen: no button. Worth a line in the console and nothing in the reader's way.
console.warn('Could not determine whether this page accepts edit suggestions.', err)
this.$patch({ canSuggestEdits: false, hasOpenSuggestion: false })
}
},
/**
* PAGE - SUGGEST EDITS
*
* Opens the editor on a suggestion rather than on the page. The source comes from the suggestion
* endpoint rather than from the page: it hands back whatever this reader already suggested, so
* that coming back to the button carries on from where they left off, and it is also the only way
* an anonymous reader gets the source at all.
*/
async pageSuggest() {
const editorStore = useEditorStore()
const siteStore = useSiteStore()
const resp = await API_CLIENT.get(`sites/${siteStore.id}/pages/${this.id}/suggestions/self`, {
searchParams: { withContent: true }
}).json()
if (!resp?.canSubmit) {
throw new Error('ERR_SUGGESTIONS_NOT_ALLOWED')
}
this.$patch({
content: resp.content ?? '',
contentLoaded: true,
canSuggestEdits: true,
hasOpenSuggestion: Boolean(resp.submission)
})
if (!editorStore.configIsLoaded) {
await editorStore.fetchConfigs()
}
const curDate = Temporal.Now.instant()
editorStore.$patch({
isActive: true,
mode: 'suggest',
editor: this.editor,
lastChangeTimestamp: curDate,
lastSaveTimestamp: curDate
})
},
/**
* PAGE - SUBMIT SUGGESTED EDITS
*
* @param {object} [guest] Name and email, required when nobody is logged in
*/
async pageSubmitSuggestion({ guestName, guestEmail } = {}) {
const siteStore = useSiteStore()
const resp = await API_CLIENT.put(`sites/${siteStore.id}/pages/${this.id}/suggestions/self`, {
json: {
content: this.content,
...(guestName ? { guestName } : {}),
...(guestEmail ? { guestEmail } : {})
}
}).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
this.hasOpenSuggestion = true
return resp.submission
},
/**
* PAGE - EDIT
*/

Loading…
Cancel
Save