fix: permissions + optimize network calls per page

scarlett
NGPixel 1 month ago
parent 0b0574861d
commit 1105cf4b5e
No known key found for this signature in database

@ -278,6 +278,49 @@ These apply to **every workspace**, `frontend/` included — not just the backen
mid-2026) and is awaited first in `main.js`. The polyfill is a lazy chunk (~21 kB gzipped) that
browsers with native `Temporal` never download.
### Permissions
There are **two kinds of permission**, granted separately and checked in different places. Which
kind a name belongs to decides how it may be enforced, so it is the first thing to establish about
any permission you touch.
**Global permissions** are held site-wide, bound to no path: `access:admin`, `manage:users`,
`manage:groups`, `manage:navigation`, `manage:theme`, `manage:sites`, `manage:system`. That list is
the whole of it — the one offered by the group editor (`GroupEditOverlay.vue`). They live on a
group's `permissions` column, are flattened onto `req.session.permissions` at login
(`models/users.ts` → `updateSession`), and are what the per-route `config.permissions` hook
checks. `manage:system` bypasses every check everywhere.
**Page rule permissions** are bound to paths, and to locales and sites: `read:pages`, `write:pages`,
`review:pages`, `manage:pages`, `delete:pages`, `write:styles`, `write:scripts`, `read:source`,
`read:history`, `read:assets`, `write:assets`, `manage:assets`, `read:comments`, `write:comments`,
`manage:comments` (`PAGE_PERMISSIONS` in `api/pages.ts`). A group grants them through **rules**:
each rule names some of them (`roles`) plus how it addresses pages (`match` + `path`, or tags) and
what it does with them (`mode`: ALLOW / DENY / FORCEALLOW). Nothing is granted by default, and when
several rules match, the most specific one wins — `helpers/pageRules.ts` documents the ordering.
Ask `WIKI.models.groups.checkAccess(actor, permission, page)`, or `mayOnPage(req, permission, page)`
in `api/pages.ts`.
Consequences worth knowing:
- **A page permission cannot be enforced by `config.permissions`.** That hook reads the group-wide
list only, so `permissions: ['write:pages']` refuses everybody. A route that turns on a page
permission declares no route permission and checks in the handler instead — say so with a
`No route-level permissions:` comment, as `api/pages.ts`, `api/assets.ts` and `api/blocks.ts` do.
- **The two names are not interchangeable.** `manage:pages` does not imply `write:pages`: a rule
grants the exact strings in its `roles`.
- **On the frontend**, `userStore.permissions` is the global list (from `users/whoami`) and
`userStore.pagePermissions` is what the session holds AT THE CURRENT PATH (from
`pages/userPermissions`, refreshed per route in `App.vue`). `userStore.can()` ORs the two and
treats `manage:system` as a wildcard, so it answers "may do this somewhere". Gate a control over
the page in front of the reader on `pagePermissions` — that is what the endpoint behind the
button will check.
- **An anonymous request is the guests group**, not an absence of groups: that is how a wiki opens
reading, and suggesting edits, to the public. Deny guests explicitly where an account is genuinely
required (`reviewerFor` in `api/approvals.ts` is the worked example).
- **Never invent a permission name.** Both lists above are closed; `can('browse:fileman')` and
friends matched nothing and silently hid the controls they guarded.
### Backend patterns
- **The `WIKI` global.** Set up in `index.ts`, typed in `types/global.d.ts`, available everywhere
@ -288,9 +331,10 @@ These apply to **every workspace**, `frontend/` included — not just the backen
- **Routes** are Fastify plugins: `async function routes(app) { ... }` with a default export.
- **Permissions** are declared per-route in `config.permissions`, and enforced by a single
`preHandler` hook in `index.ts`. The array is OR-ed; a nested array is AND-ed
(`permissions: ['read:sites', ['manage:pages', 'write:pages']]`). `manage:system` bypasses every
(`permissions: ['read:sites', ['manage:users', 'manage:groups']]`). `manage:system` bypasses every
check. `@fastify/swagger`'s `transform` folds these into the OpenAPI description automatically —
so declaring them is also how they get documented.
so declaring them is also how they get documented. Only **global** permissions belong here; see
[Permissions](#permissions) for the other kind and how they are checked.
- **Every route needs a `schema`** with `summary`, `tags`, and response schemas. `hideUntagged` is on,
so an untagged route is invisible in the API docs. Reuse `$ref` schemas from `api/schemas/`.
- **Errors** via `@fastify/sensible` helpers (`reply.notFound()`, `reply.badRequest()`,

@ -46,8 +46,17 @@ async function loadSuggestablePage(req: FastifyRequest, siteId: string, pageId:
* one the site-wide queue in the inbox it is answered at the site root, which is the only thing
* a queue spanning every page could ask about; the per-page check then still applies to each entry
* through the approval rules that produced it.
*
* Nobody reviews anything without an account. A guest is treated as a member of the guests group,
* which is right for SUBMITTING anonymous suggestions are a feature but a review is an act with
* an author: accepting one writes the page and records who accepted it. So a rule that named the
* guests group among its reviewers, or a page rule granting them `review:pages`, would otherwise hand
* the queue to the public. An empty scope reviews nothing, whatever the rules say.
*/
function reviewerFor(req: FastifyRequest, page?: { path: string; tags?: string[] }): ReviewerScope {
if (!isReviewerSession(req)) {
return { groupIds: [], reviewsAll: false }
}
const actor = WIKI.models.groups.actorForRequest(req)
return {
groupIds: WIKI.models.approvals.getActorGroupIds(req),
@ -57,6 +66,11 @@ function reviewerFor(req: FastifyRequest, page?: { path: string; tags?: string[]
}
}
/** Shorthand for the model's own check; see `isReviewerSession` there for why reviewing needs one. */
function isReviewerSession(req: FastifyRequest): boolean {
return WIKI.models.approvals.isReviewerSession(req)
}
/**
* Everything a rule has to satisfy beyond what the JSON Schema already enforces.
*
@ -619,6 +633,14 @@ async function routes(app: FastifyInstance) {
},
async (req, reply) => {
reply.preventCache()
/*
Answered before the page is even looked up. Every reader loading any page asks this, so the
one case that can be settled from the session alone is settled there: a guest reviews nothing,
and the wiki has no reason to read a page and a rule set to say so again on every page view.
*/
if (!isReviewerSession(req)) {
return { canReview: false, submissions: [] }
}
const page = await loadSuggestablePage(req, req.params.siteId, req.params.pageId)
if (!page) {
return reply.notFound('This page does not exist.')

@ -1,4 +1,57 @@
import type { FastifyInstance } from 'fastify'
import type { FastifyInstance, FastifyRequest } from 'fastify'
/**
* Group-wide permissions that carry the block list on their own.
*
* Only the ones a group really is granted as a blanket. Writing a page is NOT among them, however much
* it sounds like it belongs: page permissions come from a group's rules, and are read below.
*/
const LIST_PERMISSIONS = ['read:sites', 'manage:sites', 'manage:system']
/** The page rules that make somebody an author, i.e. able to put a block into a page directly. */
const AUTHOR_ROLES = ['write:pages', 'manage:pages']
/**
* Whether this caller has any business seeing which blocks a site has.
*
* The list is what the editor's block picker is built from, so it belongs to whoever may put a block
* into a page. Three ways of being that person:
*
* - an administrator, from the group-wide list above;
* - an author, from a page rule that lets them write somewhere on this site;
* - anyone an enabled approval rule lets SUGGEST an edit the guests group included, when a wiki
* has opened suggestions to the public. A suggestion is written in the same editor, with the same
* picker in it, and refusing the list there leaves the button throwing an error at a reader who
* was invited to use it.
*
* Asked of the site rather than of a page, because that is what the answer is about: which blocks
* exist here. Nothing in the reply is page-specific, so a rule anywhere on the site settles it what
* may be written WHERE is decided by the page and suggestion routes, as it is for everything else.
*
* The route-level permission hook cannot answer any of this: it reads the group-wide list alone, and
* both writing a page and suggesting an edit are granted by rules instead.
*/
async function mayListBlocks(req: FastifyRequest, siteId: string): Promise<boolean> {
const actor = WIKI.models.groups.actorForRequest(req)
if (LIST_PERMISSIONS.some((permission) => actor.permissions.includes(permission))) {
return true
}
// -> Both of these read cached group rules; only the last resort goes to the database
if (
WIKI.models.groups
.rulesForGroups(actor.groupIds)
.some(
(rule) => rule.mode !== 'DENY' && AUTHOR_ROLES.some((role) => rule.roles?.includes(role))
)
) {
return true
}
const groupIds = WIKI.models.approvals.getActorGroupIds(req)
const rules = await WIKI.models.approvals.getRules(siteId)
return rules.some(
(rule) => rule.isEnabled && rule.submitterGroups.some((id) => groupIds.includes(id))
)
}
/**
* Blocks API Routes
@ -10,13 +63,15 @@ async function routes(app: FastifyInstance) {
app.get<{ Params: { siteId: string } }>(
'/sites/:siteId/blocks',
{
config: {
permissions: ['read:sites', 'manage:sites']
},
/*
No route-level `permissions`: who may see this list comes down to a group's rules, which that
hook does not read and it would refuse an anonymous reader outright, when a wiki that takes
public suggestions has invited exactly that reader to use the picker. See `mayListBlocks`.
*/
schema: {
summary: 'List the blocks available to a site',
description:
'Built-in blocks are registered from the compiled block manifest, so the list reflects what is actually installed.',
'Built-in blocks are registered from the compiled block manifest, so the list reflects what is actually installed. This is what the editor builds its block picker from, so it is available to page authors and to anyone an approval rule lets suggest an edit — guests included, where a site takes public suggestions — as well as to site administrators.',
tags: ['Blocks'],
params: {
type: 'object',
@ -42,6 +97,9 @@ async function routes(app: FastifyInstance) {
if (!site) {
return reply.notFound('Site does not exist.')
}
if (!(await mayListBlocks(req, req.params.siteId))) {
return reply.forbidden('You are not allowed to list the blocks of this site.')
}
return WIKI.models.blocks.getSiteBlocks(req.params.siteId)
}
)

@ -0,0 +1,78 @@
import { whoAmI } from './users.ts'
import type { FastifyInstance } from 'fastify'
/**
* Bootstrap API Route
*
* The three things the app has to know before it can draw anything: which site it is on, which system
* flags are set, and who is asking. Each has an endpoint of its own the admin area reads the flags,
* the login flow asks who is logged in once that has changed but a full load needs all three at
* once, and asking for them one at a time is three round trips before the first pixel.
*
* None of them touches the database: the site configurations and the flags are in memory, and the
* session carries the user. So what this saves is the round trips, which is the whole cost.
*/
async function routes(app: FastifyInstance) {
app.get<{ Querystring: { hostname?: string } }>(
'/',
{
config: {
publicAccess: true
},
schema: {
summary: 'Everything the app needs to start',
description:
'The site for the hostname, the system flags, and the current session — the same answers `sites/{hostname}`, `system/flags` and `users/whoami` give, in one request.\n\nCarries the session, so it is never cached.',
tags: ['System'],
querystring: {
type: 'object',
properties: {
hostname: {
type: 'string',
maxLength: 255,
description: "The host the browser is on. The request's own hostname when absent."
}
}
},
response: {
200: {
description: 'Site, flags and session',
type: 'object',
properties: {
site: { $ref: 'Site#' },
flags: { $ref: 'SystemFlags#' },
user: {
type: 'object',
description:
'As `users/whoami` answers it: `authenticated: false` alone for a guest, otherwise the account and its group-wide permissions.',
additionalProperties: true
}
}
}
}
}
},
async (req, reply) => {
// -> The session decides part of the answer, so no shared cache may hold on to it
reply.preventCache()
const site = await WIKI.models.sites.getSiteByHostname({
hostname: req.query.hostname ?? req.hostname
})
if (!site) {
return reply.notFound('There is no wiki site at this hostname.')
}
return {
site: {
...site.config,
id: site.id,
hostname: site.hostname,
isEnabled: site.isEnabled
},
flags: WIKI.models.flags.getFlags(),
user: whoAmI(req)
}
}
)
}
export default routes

@ -30,6 +30,7 @@ async function routes(app: FastifyInstance) {
app.register(import('./assets.ts'))
app.register(import('./authentication.ts'))
app.register(import('./blocks.ts'))
app.register(import('./bootstrap.ts'), { prefix: '/bootstrap' })
app.register(import('./groups.ts'), { prefix: '/groups' })
app.register(import('./hooks.ts'), { prefix: '/hooks' })
app.register(import('./icons.ts'), { prefix: '/icons' })

@ -120,6 +120,35 @@ export function mayOnPage(
return WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), permission, page)
}
/**
* Every page permission this requester holds at a path.
*
* What the interface hides its controls by, and the reason it is a list rather than a question: each
* permission may be decided by a different rule a branch can be readable but not writable, and one
* page within it neither so they are resolved one at a time.
*
* Anonymous included: the guests group has rules of its own, and what the public may do is exactly
* what they say. Answering an empty list for a reader without a session would hide controls a wiki had
* deliberately opened to everyone.
*/
export function pagePermissionsFor(
req: FastifyRequest,
page: { path: string; locale?: string; tags?: string[] }
): string[] {
const actor = WIKI.models.groups.actorForRequest(req)
/*
An administrator holds all of them, and holds them here too. Deriving the list from their
permissions instead would answer `manage:system` nothing ending in `:pages` that an
administrator has no rights over any page, which is the opposite of true.
*/
if (actor.permissions.includes('manage:system')) {
return PAGE_PERMISSIONS
}
return PAGE_PERMISSIONS.filter((permission) =>
WIKI.models.groups.checkAccess(actor, permission, page)
)
}
/**
* A page, as this requester is allowed to see it or null when they are not allowed to see it at all.
*
@ -449,7 +478,26 @@ async function routes(app: FastifyInstance) {
if (!mayOnPage(req, 'read:pages', page)) {
return reply.forbidden('You are not allowed to read this page.')
}
return page
/*
The reader's own standing on this page, carried back with it.
Three questions the page view used to ask as three more requests what may I do here, may I
suggest an edit, do I review this page each of which had to load the page again to answer.
They are answered here from the page already in hand, against rules already in memory, which
is what makes a page view one request instead of four.
*/
return {
...page,
viewer: {
permissions: pagePermissionsFor(req, page),
...(await WIKI.models.approvals.pageViewerState(req, req.params.siteId, {
id: page.id,
path: page.path,
tags: page.tags ?? [],
allowContributions: page.allowContributions
}))
}
}
}
)
@ -1034,26 +1082,7 @@ async function routes(app: FastifyInstance) {
}
},
async (req) => {
/*
Anonymous included: the guests group has rules of its own, and what the public may do is
exactly what they say. Answering an empty list for a reader without a session would hide
controls a wiki had deliberately opened to everyone.
*/
const accessActor = WIKI.models.groups.actorForRequest(req)
/*
An administrator holds all of them, and holds them here too. Deriving the list from their
permissions instead would answer `manage:system` nothing ending in `:pages` that an
administrator has no rights over any page, which is the opposite of true.
*/
if (accessActor.permissions.includes('manage:system')) {
return PAGE_PERMISSIONS
}
// -> Resolved per permission against this path, since each one may be decided by a different
// rule — a branch can be readable but not writable, and one page within it neither
const page = { path: req.body.path.replace(/^\/+/, '') }
return PAGE_PERMISSIONS.filter((permission) =>
WIKI.models.groups.checkAccess(accessActor, permission, page)
)
return pagePermissionsFor(req, { path: req.body.path.replace(/^\/+/, '') })
}
)
}

@ -202,7 +202,39 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
authorId: { type: 'string', format: 'uuid' },
authorName: { type: 'string' },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' }
updatedAt: { type: 'string', format: 'date-time' },
viewer: {
type: 'object',
description:
'Where the requester stands on this page: what they may do to it, whether they may suggest an edit, and whether they review it. Present when a page is fetched on its own — the page view draws its controls from this, rather than asking three further endpoints about a page it already has. Absent from a page returned by a save.',
properties: {
permissions: {
type: 'array',
items: { type: 'string' },
description:
'The page permissions held AT THIS PATH, as this readers groups rules decide. The same answer `pages/userPermissions` gives for the path.'
},
canSuggestEdits: {
type: 'boolean',
description:
'An enabled approval rule covers this page and names a group the requester is in, and the page allows contributions.'
},
hasOpenSuggestion: {
type: 'boolean',
description:
'The requester already has a suggestion waiting on this page, which they would carry on with rather than start again. Always false for a guest, whose suggestions are attributed to nobody.'
},
canReview: {
type: 'boolean',
description: 'The requester reviews this page. Always false without an account.'
},
pendingSubmissions: {
type: 'array',
items: { $ref: 'PageEditSubmission#' },
description: 'What is waiting on this page, oldest first. Empty unless `canReview`.'
}
}
}
}
})

@ -27,6 +27,30 @@ function sessionUserId(req: FastifyRequest): string | null {
return req.session?.authenticated && req.session.user?.id ? req.session.user.id : null
}
/**
* Who is asking, as the interface needs to know it: the account on the session and the group-wide
* permissions it holds, or nothing at all for a guest.
*
* Exported because `bootstrap` answers the same question as part of the one call an app load makes,
* and two versions of "who is this" would be one too many.
*/
export function whoAmI(req: FastifyRequest): Record<string, any> {
if (!req.session?.authenticated) {
return { authenticated: false }
}
return {
authenticated: true,
...req.session.user,
/*
The same list the route permission hook checks against written onto the session at login from
the groups the user belongs to. Nothing is added for the interface's benefit: a control it shows
on a permission the session does not hold leads to a button that gets a 403 from the endpoint
behind it.
*/
permissions: req.session.permissions ?? []
}
}
/**
* Whether self-service profile editing is enabled on the site being browsed.
*
@ -120,22 +144,14 @@ async function routes(app: FastifyInstance) {
{
schema: {
summary: 'Get currently logged in user info',
description:
'Includes the group-wide permissions of the session, which is what the interface hides its own controls by. Permissions ON A PAGE are a different question, answered by `pages/userPermissions`.\n\nThe app itself gets this from `bootstrap` on load, together with the site and the flags; this endpoint is what asks again once a login or a logout has changed the answer.',
tags: ['Users']
}
},
async (req, reply) => {
reply.preventCache()
if (req.session?.authenticated) {
return {
authenticated: true,
...req.session.user,
permissions: ['manage:system'] // TODO: pull actual permissions
}
} else {
return {
authenticated: false
}
}
return whoAmI(req)
}
)

@ -142,6 +142,8 @@ async function postBoot() {
await WIKI.models.sites.reloadCache()
// -> Page access is decided from these on every request, so they are in memory from the start
await WIKI.models.groups.reloadCache()
// -> Likewise: every page view asks whether the page takes suggestions and who reviews it
await WIKI.models.approvals.reloadCache()
// -> Must follow the sites cache: every site gets a row per installed block
await WIKI.models.blocks.refreshFromDisk()

@ -136,6 +136,19 @@ const ruleSelection = {
updatedAt: approvalRulesTable.updatedAt
}
/**
* Every site's rules, by site id, in the order `getRules` promises.
*
* Cached for the reason the group rules are (`models/groups.ts`): whether a page takes suggestions
* and who reviews it are questions the page view asks about every page it draws, and answering them
* from the database would put two queries in front of every page read. Rules change from one admin
* screen, and the cache is reloaded there.
*
* A single instance's memory, like the group and site caches beside it: a rule changed on one node of
* a cluster reaches the others when they next reload.
*/
let rulesCache: Record<string, ApprovalRule[]> = {}
/**
* Approvals model
*
@ -143,22 +156,41 @@ const ruleSelection = {
* submissions themselves are a separate concern and are not stored yet.
*/
class Approvals {
/**
* Reload every site's rules into memory.
*
* Called at boot and after any change to a rule, so that an administrator's edit takes effect on the
* next request the same contract `models/groups.ts` gives page rules.
*/
async reloadCache(): Promise<void> {
const rows = (await WIKI.db
.select({ ...ruleSelection, siteId: approvalRulesTable.siteId })
.from(approvalRulesTable)
.orderBy(
asc(sql`lower(${approvalRulesTable.name})`),
asc(approvalRulesTable.createdAt)
)) as (ApprovalRule & { siteId: string })[]
rulesCache = {}
for (const { siteId, ...rule } of rows) {
rulesCache[siteId] ??= []
rulesCache[siteId].push(rule as ApprovalRule)
}
WIKI.logger.info(`Loaded ${rows.length} approval rules [ OK ]`)
}
/**
* 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.
*
* From `rulesCache`, so this costs nothing to ask; async because every caller awaits it and because
* where the rules come from is this model's business. The array is the cached one read it, do not
* sort or splice it.
*/
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[]>
return rulesCache[siteId] ?? []
}
/**
@ -215,6 +247,8 @@ class Approvals {
reviewerGroups: patch.reviewerGroups ?? []
})
.returning(ruleSelection)
// -> Every rule read afterwards comes from the cache, so it has to know about this one
await this.reloadCache()
return rows[0] as ApprovalRule
}
@ -248,6 +282,7 @@ class Approvals {
.set(values)
.where(and(eq(approvalRulesTable.siteId, siteId), eq(approvalRulesTable.id, id)))
.returning(ruleSelection)
await this.reloadCache()
return (rows[0] as ApprovalRule) ?? null
}
@ -357,6 +392,77 @@ class Approvals {
)
}
/**
* Whether this request could review anything at all, i.e. it is a logged in user.
*
* Reads the session and nothing else, so a guest can be turned away before a single query is made on
* their behalf. A guest counts as a member of the guests group everywhere else, which is right for
* SUBMITTING anonymous suggestions are a feature but a review is an act with an author.
*/
isReviewerSession(req: any): boolean {
return Boolean(req.session?.authenticated && req.session.user?.id)
}
/**
* Where this reader stands on this page: may they suggest an edit to it, and do they review it.
*
* Answered here, in one place, because it is answered on EVERY page view the page route carries it
* back with the page rather than leaving the browser to ask two more questions about a page it has
* just been given. The cost is kept to what is actually needed: the rules are in memory, and neither
* of the two queries below is reached by a reader the rules say nothing about.
*
* @param req The request, for its session; both answers are about who is asking
*/
async pageViewerState(
req: any,
siteId: string,
page: ApprovalPageRef
): Promise<{
canSuggestEdits: boolean
hasOpenSuggestion: boolean
canReview: boolean
pendingSubmissions: ReviewableSubmission[]
}> {
const actorId = req.session?.authenticated ? (req.session.user?.id ?? null) : null
const groupIds = this.getActorGroupIds(req)
const submitRule = await this.findSubmitRule(siteId, page, groupIds)
/*
Only a logged in author can have one waiting: a guest suggestion is attributed to nobody, so
there is nothing to look up and nothing to carry on from. `getOwnSubmission` says the same, and
this keeps the query from being made at all.
*/
const hasOpenSuggestion = Boolean(
submitRule && actorId && (await this.getOwnSubmission(page.id, actorId))
)
const reviewerScope = this.isReviewerSession(req)
? {
groupIds,
reviewsAll:
(req.session?.permissions ?? []).includes('manage:system') ||
WIKI.models.groups.checkAccess(
WIKI.models.groups.actorForRequest(req),
'review:pages',
{
path: page.path,
tags: page.tags
}
)
}
: { groupIds: [], reviewsAll: false }
const canReview = await this.canReviewPage(siteId, page, reviewerScope)
return {
canSuggestEdits: Boolean(submitRule),
hasOpenSuggestion,
canReview,
pendingSubmissions: canReview
? await this.getReviewableSubmissions(siteId, { ...reviewerScope, pageId: page.id })
: []
}
}
/**
* The suggestion this user already has open on this page, if any.
*
@ -685,6 +791,7 @@ class Approvals {
const result = await WIKI.db
.delete(approvalRulesTable)
.where(and(eq(approvalRulesTable.siteId, siteId), eq(approvalRulesTable.id, id)))
await this.reloadCache()
return (result.rowCount ?? 0) > 0
}
}

@ -184,6 +184,31 @@ if (typeof siteConfig !== 'undefined') {
applyTheme()
}
/**
* Everything the app has to know before it can draw: which site it is on, which system flags are set,
* and who is asking.
*
* The three have endpoints of their own, and are still asked separately where they change on their
* own the admin area saves flags, a login changes who is asking. This is the load, where all three
* are wanted at once and none of them is known yet.
*
* A failure leaves the stores at their defaults and says so in the console, which is what the three
* calls did before: there is no interface yet to put an error in front of.
*/
async function loadBootstrap() {
try {
const data = await API_CLIENT.get('bootstrap', {
searchParams: { hostname: window.location.hostname },
cache: 'no-store'
}).json()
siteStore.applySiteInfo(data.site)
flagsStore.apply(data.flags)
userStore.applyProfile(data.user)
} catch (err) {
console.warn(`Could not load the site configuration: ${err.message}`)
}
}
// ROUTE GUARDS
router.beforeEach(async (to, from) => {
@ -194,16 +219,14 @@ router.beforeEach(async (to, from) => {
// userStore.loadToken()
// }
// -> System Flags
if (!flagsStore.loaded) {
flagsStore.load()
}
// -> Site Info
if (!siteStore.id) {
console.info('No pre-cached site config. Loading site info...')
await siteStore.loadSite(window.location.hostname)
console.info(`Using Site ID ${siteStore.id}`)
/*
-> Site info, system flags and the session
One request for the three of them: none touches the database, so what they cost is the round trip,
and a full load paid it three times over before it could draw anything. Asked once a guest is an
answer like any other, so this does not run again on the way to the next page.
*/
if (!siteStore.id || !flagsStore.loaded || !userStore.profileLoaded) {
await loadBootstrap()
}
// -> Locale
@ -215,14 +238,16 @@ router.beforeEach(async (to, from) => {
}
applyLocale(commonStore.desiredLocale)
// -> User Profile
if (!userStore.profileLoaded) {
console.info(`Refreshing user profile...`)
await userStore.refreshProfile()
/*
-> Page Permissions
Not fetched here any more: what this reader may do at a path comes back with the page itself, so
a page view is one request rather than two. What is left is the routes that are not a page
dropping the last page's permissions on the way out of the page view, which takes no request at
all. A path with no page behind it has nothing to carry them, and asks in `pages/Index.vue`.
*/
if (to.path.startsWith('/_')) {
userStore.$patch({ pagePermissions: [] })
}
// -> Page Permissions
await userStore.fetchPagePermissions(to.path)
})
// GLOBAL EVENTS HANDLERS

@ -266,7 +266,7 @@
<div class="min-w-0 flex-1">
<w-scroll-area :thumb-style="thumbStyle" :bar-style="barStyle" style="height: 100%">
<div class="fileman-loadinglist" v-if="state.fileListLoading">
<w-spinner class="mr-2" color="primary" size="64px" :thickness="1" />
<w-spinner class="mr-2" color="primary" size="64px" />
<span class="text-primary">Fetching folder contents...</span>
</div>
<div class="fileman-emptylist" v-else-if="files.length < 1">

@ -30,8 +30,14 @@
<w-tooltip>Create New Page</w-tooltip>
<new-menu />
</w-btn>
<!--
-> Whoever may put a file somewhere: `write:assets` outright, or `write:pages` for an author
whose rules cover the pages but not the assets beside them, since the editor sends them
here to insert an image. Every folder and every file is checked again by the endpoints
behind the manager, which answer per path, so this decides only whether the door is shown.
-->
<w-btn
v-if="userStore.can(`browse:fileman`)"
v-if="userStore.can(`write:assets`) || userStore.can(`write:pages`)"
class="ml-4"
flat
round

@ -12,7 +12,7 @@
<div
class="page-actions flex flex-col items-stretch order-last"
:class="editorStore.isActive ? `is-editor` : ``">
<template v-if="userStore.can(`edit:pages`)">
<template v-if="userStore.can(`write:pages`)">
<w-btn
class="aspect-square"
flat
@ -29,11 +29,12 @@
is why it is outside that group rather than in it.
Only for whoever reviews this page: the server answers `canReview` from the approval rules and
the reviewer's own permissions, so nothing here has to know how that is decided.
the reviewer's own permissions -- with the page itself -- so nothing here has to know how that
is decided, or ask about it.
-->
<w-btn
class="h-12"
v-if="state.canReview"
v-if="canReview"
flat
:color="editorStore.isActive ? `white` : `deep-orange-9`"
aria-label="Pending Edit Suggestions">
@ -72,7 +73,7 @@
</w-item-section>
</w-item>
<w-item
v-for="submission of state.submissions"
v-for="submission of pageStore.pendingSubmissions"
:key="submission.id"
clickable
@click="reviewSubmission(submission)">
@ -92,7 +93,7 @@
</w-list>
</w-menu>
</w-btn>
<template v-if="userStore.can(`edit:pages`)">
<template v-if="userStore.can(`write:pages`)">
<w-btn
class="h-12"
v-if="flagsStore.experimental"
@ -209,7 +210,7 @@
</w-item-section>
<w-item-section><w-item-label>Convert Page</w-item-label></w-item-section>
</w-item>
<w-item clickable v-if="userStore.can(`edit:pages`)" @click="rerenderPage">
<w-item clickable v-if="userStore.can(`write:pages`)" @click="rerenderPage">
<w-item-section class="items-center" avatar>
<w-icon class="text-deep-orange-9" name="la:magic" size="sm" />
</w-item-section>
@ -234,7 +235,7 @@
<template v-if="!(editorStore.isActive && [`create`, `suggest`].includes(editorStore.mode))">
<w-btn
class="h-12"
v-if="userStore.can(`create:pages`)"
v-if="userStore.can(`write:pages`)"
flat
icon="la:copy"
:color="editorStore.isActive ? `deep-orange-2` : `grey`"
@ -273,7 +274,7 @@
</template>
<script setup>
import { computed, defineAsyncComponent, onMounted, reactive, ref, watch } from 'vue'
import { computed, defineAsyncComponent, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
@ -309,27 +310,18 @@ const menuPendingAssets = ref(null)
// DATA
const state = reactive({
/** Whether this user reviews this page at all, which is what shows the button. */
canReview: false,
/** What is waiting on it, oldest first. */
submissions: []
})
// COMPUTED
const hasPendingAssets = computed(() => editorStore.pendingAssets?.length > 0)
const pendingCount = computed(() => state.submissions.length)
// WATCHERS
// -> Per page, so navigating between pages asks again rather than carrying the last one's answer
watch(() => pageStore.id, loadSubmissions)
/*
Both from the page itself: the page route answers who reviews it and what is waiting on it, along
with everything else this rail is drawn from. The rail asked for them separately until it turned out
to be a third request about a page the view had already been given.
*/
const canReview = computed(() => pageStore.canReview)
// MOUNTED
onMounted(loadSubmissions)
const pendingCount = computed(() => pageStore.pendingSubmissions.length)
// METHODS
@ -340,29 +332,6 @@ function humanizeDate(val) {
})
}
/**
* What is waiting on this page, if this user is one of its reviewers.
*
* Quietly on failure: the rail is not where a reader finds out that a request went wrong, and a
* button that does not appear is the same outcome as not being a reviewer.
*/
async function loadSubmissions() {
state.canReview = false
state.submissions = []
if (!pageStore.id || !userStore.authenticated) {
return
}
try {
const resp = await API_CLIENT.get(
`sites/${siteStore.id}/pages/${pageStore.id}/submissions`
).json()
state.canReview = resp?.canReview === true
state.submissions = resp?.submissions ?? []
} catch (err) {
console.warn(err)
}
}
/**
* Open one for review, remembering where it was opened from.
*

@ -145,7 +145,7 @@
into the pending state without an editor behind it, and hiding Edit there left no way back into
the content at all. Ahead of the commit actions so those stay rightmost.
-->
<template v-if="!editorStore.isActive && userStore.can(`edit:pages`)">
<template v-if="!editorStore.isActive && userStore.can(`write:pages`)">
<w-btn
class="acrylic-btn ml-4"
flat

@ -57,7 +57,7 @@
<w-page-container>
<w-page v-if="state.loading > 0">
<div class="flex p-6 items-center">
<w-spinner color="primary" size="32px" :thickness="2" />
<w-spinner color="primary" size="32px" />
<div class="text-caption text-primary pl-4">
<strong>{{ t('admin.users.loading') }}</strong>
</div>

@ -36,7 +36,13 @@
</w-list>
</w-menu>
</w-btn>
<!--
-> Same test the admin area itself makes on arrival: this screen greets whoever may write the
first page, which on a wiki with an editors group is not necessarily somebody who may
administer it -- and the button would land them on the unauthorized screen.
-->
<w-btn
v-if="userStore.can(`access:admin`)"
push
color="primary"
:label="t(`welcome.admin`)"
@ -59,13 +65,14 @@ import { useMeta } from '@/composables/meta'
import { useFlagsStore } from '@/stores/flags'
import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
// STORES
const flagsStore = useFlagsStore()
const pageStore = usePageStore()
const siteStore = useSiteStore()
const userStore = useUserStore()
// ROUTER

@ -5,7 +5,14 @@
the shell instead and take the sidebars with it.
-->
<w-page class="flex flex-col h-full min-h-0">
<div class="page-breadcrumbs py-2 px-4 flex flex-wrap" v-if="!editorStore.isActive">
<!--
Both bars are about a page: where it sits and when it was last written to. A path with no page
has neither to report -- the trail would end on a crumb that leads nowhere and the bar would read
"Last modified on N/A" -- so the missing-page screen below is the whole column.
-->
<div
class="page-breadcrumbs py-2 px-4 flex flex-wrap"
v-if="!editorStore.isActive && !pageStore.notFound">
<div class="min-w-0 flex-1">
<w-breadcrumbs
:items="breadcrumbs"
@ -24,7 +31,7 @@
</div>
</div>
</div>
<page-header />
<page-header v-if="!pageStore.notFound" />
<!-- -> `min-h-0` so the columns inside can be shorter than their content and scroll -->
<div class="page-container flex min-h-0 flex-nowrap items-stretch" style="flex: 1 1 100%">
<div
@ -35,8 +42,8 @@
The lock screen, in place of the article. There is nothing to hide here: the server sent no
body at all, so this is the whole of what arrived for a protected page.
-->
<div v-else-if="pageStore.isLocked" class="page-locked">
<w-icon class="page-locked-icon" name="la:lock" />
<div v-else-if="pageStore.isLocked" class="page-placeholder">
<w-icon class="page-placeholder-icon" name="la:lock" />
<div class="text-h6">{{ t('common.page.locked') }}</div>
<div class="text-body2 mt-1 opacity-60">{{ t('common.page.lockedHint') }}</div>
<w-btn
@ -48,6 +55,48 @@
:label="t(`common.page.unlock`)"
@click="promptUnlock" />
</div>
<!--
The same column for a path with no page behind it, which is a state of this view rather than
an error screen: the reader is still inside the wiki, at a URL that could hold a page, and
for anyone who may write one the answer to "this page does not exist" is the button that
creates it -- at this path, so that the link they followed leads somewhere afterwards.
-->
<div v-else-if="pageStore.notFound" class="page-placeholder">
<w-icon class="page-placeholder-icon" name="la:file-alt" />
<!-- -> "...yet" is an invitation, so it is for whoever can take it up; to a reader who
cannot write here the page simply does not exist -->
<div class="text-h6">
{{ canCreatePage ? t('common.newpage.title') : t('common.notfound.subtitle') }}
</div>
<div class="text-body2 mt-1 opacity-60" v-if="canCreatePage">
{{ t('common.newpage.subtitle') }}
</div>
<!--
The path itself, because the sentence above is about a page the reader cannot see and this
is the one thing that says WHICH page: the link they followed, and what the button is about
to create.
-->
<div class="text-caption font-robotomono mt-3 opacity-50">/{{ pageStore.path }}</div>
<w-btn
class="mt-6"
v-if="canCreatePage"
unelevated
icon="la:plus"
color="primary"
padding="xs lg"
:label="t(`common.newpage.create`)"
@click="createPage" />
<!-- -> Nothing to create for this reader, so the way out is the way they came -->
<w-btn
class="mt-6"
v-else
outline
icon="la:arrow-left"
color="primary"
padding="xs lg"
:label="t(`common.newpage.goback`)"
@click="goBack" />
</div>
<w-scroll-area class="page-container-scrl" v-else style="height: 100%">
<div class="page-container-body p-4">
<!--
@ -207,7 +256,8 @@
</div>
</template>
</div>
<page-actions-col />
<!-- -> Every action on it acts on a page: there is none here to edit, share, rate or delete -->
<page-actions-col v-if="!pageStore.notFound" />
</div>
<side-dialog />
</w-page>
@ -312,7 +362,9 @@ const showSidebar = computed(() => {
pageStore.showSidebar &&
siteStore.showSidebar &&
siteStore.theme.tocPosition !== 'off' &&
!editorStore.isActive
!editorStore.isActive &&
// -> Contents, tags and a rating, all of a page that is not there
!pageStore.notFound
)
})
/*
@ -337,11 +389,10 @@ const showToc = computed(() => {
go up with the rest of the page rather than through an endpoint of their own. So the test is the pair
the PATCH route accepts: `write:pages` or `manage:pages`.
Read off `pagePermissions` rather than through `userStore.can()`, which would answer true for
everybody: `can()` also consults `userStore.permissions`, and `users/whoami` still fills that with a
hardcoded `['manage:system']` for every session -- a TODO in `api/users.ts`. `pagePermissions` comes
from `pages/userPermissions`, which reads what the session actually holds. Once whoami is fixed this
check needs no change: that route stays the authority on what a user may do to a page.
Read off `pagePermissions` rather than through `userStore.can()`, which asks a broader question: the
group-wide list from `whoami` says what a user may do somewhere, and the rules decide where. What
they may do HERE is what `pages/userPermissions` answers, and it is the same authority the PATCH
route itself consults.
*/
const canEditPage = computed(() =>
['write:pages', 'manage:pages'].some((permission) =>
@ -349,6 +400,20 @@ const canEditPage = computed(() =>
)
)
/*
Whether the missing-page screen offers to create the page. `write:pages` at THIS path, from the same
list as the tag button above: page rules are written against paths, not against pages, so they answer
for one that does not exist yet and it is the check the create endpoint itself makes. The group-wide
list would say "may write pages somewhere", which is how a button ends up leading to a 403.
The editor is part of the answer: creating a page opens one, and markdown is the only editor this
view can mount. A site with it switched off has nothing to open, so the screen says the page is
missing and leaves it at that.
*/
const canCreatePage = computed(
() => userStore.pagePermissions.includes('write:pages') && siteStore.editors.markdown
)
const relationsLeft = computed(() => {
return pageStore.relations ? pageStore.relations.filter((r) => r.position === 'left') : []
})
@ -520,10 +585,15 @@ watch(
siteStore.overlay = 'Welcome'
}
} else {
notify({
type: 'negative',
message: 'This page does not exist (yet)!'
})
// -> Not a notification over the page the reader came from: that page is still on screen
// behind it, at a URL that is not its own. The view draws the missing page instead.
pageStore.pageNotFound({ path: newValue })
/*
The one place the page permissions have to be asked for on their own: everywhere else they
arrive with the page, and here there is no page to carry them while the screen about to
be drawn offers to create one, which is a permission question.
*/
await userStore.fetchPagePermissions(newValue)
}
} else if (err.message === 'ERR_PAGE_UNAUTHORIZED') {
// -> `replace`, so the back button leaves the wiki the way it came rather than bouncing off
@ -577,10 +647,40 @@ function onContentClick(ev) {
function promptUnlock() {
dialog({ component: PageUnlockDialog })
}
/**
* Opens the editor on the page that is not there, at the path that was asked for.
*
* The path comes from the store rather than from the route, because the route is where it goes: the
* editor moves to `/_create/markdown` and the path travels in the page itself, which is the same way
* every other New Page button works.
*/
async function createPage() {
loading.show()
await pageStore.pageCreate({ editor: 'markdown', path: pageStore.path })
loading.hide()
}
/**
* Back out of a path that has no page. `router.back()` alone lands on the wiki's own error screen for
* a reader who arrived at this URL directly, having nothing to go back to, so that case goes home.
*/
function goBack() {
if (window.history.state?.back) {
router.back()
} else {
router.push('/')
}
}
</script>
<style lang="scss">
.page-locked {
/*
The column in place of the article: the lock screen, and the page that does not exist. Both are the
same shape -- a large faint icon, a sentence, and the one button that does something about it -- and
share the styling so they cannot drift apart.
*/
.page-placeholder {
display: flex;
height: 100%;
flex-direction: column;
@ -607,7 +707,7 @@ function promptUnlock() {
Large and faint. It is the illustration on an otherwise empty column, not something to look at -- the
sentence under it is what the reader is here to read.
*/
.page-locked-icon {
.page-placeholder-icon {
margin-bottom: 24px;
font-size: 96px;
opacity: 0.12;

@ -14,18 +14,24 @@ export const useFlagsStore = defineStore('flags', {
async load() {
try {
const systemFlags = await API_CLIENT.get('system/flags').json()
if (systemFlags) {
this.$patch({
...systemFlags,
loaded: true
})
} else {
if (!systemFlags) {
throw new Error('Could not fetch system flags.')
}
this.apply(systemFlags)
} catch (err) {
console.warn(err.message)
throw err
}
},
/**
* Take in flags that arrived with something else `bootstrap` hands them over with the site and
* the session, which is how an app load gets them without a request of its own.
*/
apply(systemFlags) {
this.$patch({
...systemFlags,
loaded: true
})
}
}
})

@ -49,6 +49,12 @@ export const usePageStore = defineStore('page', {
locale: 'en',
navigationId: null,
navigationMode: 'inherit',
/**
* Whether the path in the URL has no page at all. Set by `pageNotFound`, which empties everything
* else here at the same time so this being true means the store holds the *absence* of a page,
* not a page that failed to load with the previous one's title and body still in it.
*/
notFound: false,
password: '',
path: '',
publishEndDate: '',
@ -78,7 +84,11 @@ export const usePageStore = defineStore('page', {
*/
canSuggestEdits: false,
/** Whether the reader already has a suggestion open on this page, which they would carry on with. */
hasOpenSuggestion: false
hasOpenSuggestion: false,
/** Whether this reader reviews this page, which is what shows the review button on it. */
canReview: false,
/** The suggestions waiting on this page, oldest first. Empty for everybody who is not its reviewer. */
pendingSubmissions: []
}),
getters: {
breadcrumbs: (state) => {
@ -110,14 +120,16 @@ export const usePageStore = defineStore('page', {
const editorStore = useEditorStore()
const siteStore = useSiteStore()
/*
The lock belongs to the page being loaded, not to the one before it.
The lock, and the absence of a page, belong to the page being loaded rather than to the one
before it.
Everything else in this store stays put until the reply arrives, deliberately -- blanking it
would flash an empty page on every navigation. `isLocked` cannot be treated that way: it is
read as "the page on screen is protected", and left standing it makes the NEXT page look
protected for as long as the request takes.
would flash an empty page on every navigation. These two cannot be treated that way: they are
read as "the page on screen is protected" and "there is no page on screen", and left standing
they make the NEXT page look protected, or missing, for as long as the request takes.
*/
this.isLocked = false
this.notFound = false
try {
const pageData = await API_CLIENT.get(
`sites/${siteStore.id}/pages/${id ?? fastHash(normalizePath(path))}`,
@ -141,24 +153,13 @@ export const usePageStore = defineStore('page', {
),
tocDepth: pick(pageData.tocDepth, ['min', 'max'])
})
this.applyViewerState(pageData.viewer)
// Update editor state timestamps
const curDate = Temporal.Now.instant()
editorStore.$patch({
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
@ -205,6 +206,73 @@ export const usePageStore = defineStore('page', {
tocDepth: pick(pageData.tocDepth, ['min', 'max'])
})
},
/**
* PAGE - APPLY VIEWER STATE
*
* Takes in the `viewer` block the page came with: what this reader may do here, whether they may
* suggest an edit, and what they have to review on this page. The page view used to ask three
* further endpoints for exactly this, each of which loaded the page again to answer so the one
* request now settles what the whole view draws.
*
* The page permissions go to the user store, which is where everything reads them from: they are
* the reader's, not the page's, and `userStore.can()` consults them for the path in front of them.
*
* @param viewer Absent from a page that came back from a save or an unlock, which changes none of
* this so nothing here is touched in that case.
*/
applyViewerState(viewer) {
if (!viewer) {
return
}
const userStore = useUserStore()
userStore.$patch({ pagePermissions: viewer.permissions ?? [] })
this.$patch({
canSuggestEdits: viewer.canSuggestEdits === true,
hasOpenSuggestion: viewer.hasOpenSuggestion === true,
canReview: viewer.canReview === true,
pendingSubmissions: viewer.pendingSubmissions ?? []
})
},
/**
* PAGE - NOT FOUND
*
* Puts the store in front of a path that has no page, so that the view can offer to create one.
*
* A load that fails leaves the previous page standing see `pageLoad`, where that is on purpose
* and for a path with nothing behind it that means the reader is left reading the page they came
* from under a URL that is not its own. So everything the page view draws is emptied here, and
* `path` becomes the one that was asked for the only thing about a page that does not exist that
* is actually known, and what the create button goes on to make a page at.
*
* @param {string} path The path that was requested, with or without its leading slash.
*/
pageNotFound({ path }) {
this.$patch({
id: '',
path: (path ?? '').replace(/^\/+/, ''),
title: '',
description: '',
icon: DEFAULT_PAGE_ICON,
content: '',
contentLoaded: false,
render: '',
toc: [],
tags: [],
relations: [],
scriptJsLoad: '',
scriptJsUnload: '',
scriptCss: '',
createdAt: '',
updatedAt: '',
publishState: '',
isLocked: false,
canSuggestEdits: false,
hasOpenSuggestion: false,
canReview: false,
pendingSubmissions: [],
notFound: true
})
},
/**
* PAGE - GET PATH FROM ALIAS
*/
@ -295,6 +363,8 @@ export const usePageStore = defineStore('page', {
render: '',
isBrowsable: true,
isSearchable: true,
// -> The page being created is very often the one that was missing, and it is not missing now
notFound: false,
mode: 'edit'
})
},
@ -323,33 +393,6 @@ 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
*

@ -161,51 +161,57 @@ export const useSiteStore = defineStore('site', {
async loadSite(hostname) {
try {
const siteInfo = await API_CLIENT.get(`sites/${hostname}`).json()
if (siteInfo) {
this.$patch({
id: siteInfo.id,
hostname: siteInfo.hostname,
title: siteInfo.title,
description: siteInfo.description,
logoText: siteInfo.logoText,
company: siteInfo.company,
contentLicense: siteInfo.contentLicense,
footerExtra: siteInfo.footerExtra,
features: {
...this.features,
...siteInfo.features
},
auth: {
...this.auth,
...siteInfo.auth
},
editors: {
asciidoc: siteInfo.editors.asciidoc.isActive,
markdown: siteInfo.editors.markdown.isActive,
wysiwyg: siteInfo.editors.wysiwyg.isActive
},
// -> Spread over the state defaults, as `features` and `theme` above do, so a key the
// site config has never been saved with reads as its default rather than undefined
locales: {
...this.locales,
...siteInfo.locales,
active: sortBy(describeLocales(siteInfo.locales.active), ['nativeName', 'name'])
},
tags: [],
tagsLoaded: false,
theme: {
...this.theme,
...siteInfo.theme
}
})
} else {
if (!siteInfo) {
throw new Error('Invalid Site')
}
this.applySiteInfo(siteInfo)
} catch (err) {
console.warn(err.message)
throw err
}
},
/**
* Take in a site configuration that arrived with something else `bootstrap` hands it over with
* the flags and the session, which is how an app load gets all three in one request.
*/
applySiteInfo(siteInfo) {
this.$patch({
id: siteInfo.id,
hostname: siteInfo.hostname,
title: siteInfo.title,
description: siteInfo.description,
logoText: siteInfo.logoText,
company: siteInfo.company,
contentLicense: siteInfo.contentLicense,
footerExtra: siteInfo.footerExtra,
features: {
...this.features,
...siteInfo.features
},
auth: {
...this.auth,
...siteInfo.auth
},
editors: {
asciidoc: siteInfo.editors.asciidoc.isActive,
markdown: siteInfo.editors.markdown.isActive,
wysiwyg: siteInfo.editors.wysiwyg.isActive
},
// -> Spread over the state defaults, as `features` and `theme` above do, so a key the
// site config has never been saved with reads as its default rather than undefined
locales: {
...this.locales,
...siteInfo.locales,
active: sortBy(describeLocales(siteInfo.locales.active), ['nativeName', 'name'])
},
tags: [],
tagsLoaded: false,
theme: {
...this.theme,
...siteInfo.theme
}
})
},
async fetchTags(forceRefresh = false) {
if (this.tagsLoaded && !forceRefresh) {
return

@ -12,7 +12,7 @@ const pad = (value) => String(value).padStart(2, '0')
* The stored preference is one of a handful of explicit patterns, or an empty string meaning "whatever
* this locale does" which is the only case a formatter can be left to decide on its own.
*/
function formatDatePart (zoned, dateFormat) {
function formatDatePart(zoned, dateFormat) {
switch (dateFormat) {
case 'DD/MM/YYYY':
return `${pad(zoned.day)}/${pad(zoned.month)}/${zoned.year}`
@ -56,7 +56,7 @@ function toUserZone(date, timezone) {
* Render the time part. `hourCycle` rather than `hour12: false`, which some locales render as 24:00
* where they mean 00:00.
*/
function formatTimePart (zoned, timeFormat) {
function formatTimePart(zoned, timeFormat) {
return zoned.toLocaleString(
undefined,
timeFormat === '24h'
@ -85,40 +85,49 @@ export const useUserStore = defineStore('user', {
actions: {
async refreshProfile() {
try {
const resp = await API_CLIENT.get('users/whoami', {
cache: 'no-store'
}).json()
if (!resp || !resp.authenticated) {
this.setToGuest()
} else {
this.$patch({
/*
Kept, rather than left at the guest id this store starts with. Nothing used to read it
while logged in, so nothing noticed -- but a live editing session identifies its
participants by it, and every one of them claiming the guest id makes a roomful of
people look like one person wearing the same colour.
*/
id: resp.id,
name: resp.name || 'Unknown User',
email: resp.email,
hasAvatar: resp.hasAvatar ?? false,
location: resp.location || '',
jobTitle: resp.jobTitle || '',
pronouns: resp.pronouns || '',
timezone: resp.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || '',
dateFormat: resp.dateFormat || '',
timeFormat: resp.timeFormat || '12h',
appearance: resp.appearance || 'site',
cvd: resp.cvd || 'none',
permissions: resp.permissions || [],
authenticated: true,
profileLoaded: true
})
}
this.applyProfile(
await API_CLIENT.get('users/whoami', {
cache: 'no-store'
}).json()
)
} catch (err) {
console.warn(err)
}
},
/**
* Take in a session that arrived with something else `bootstrap` hands it over with the site and
* the flags, which is how an app load asks who is logged in without a request of its own. Asking
* again is what `refreshProfile` above is for, once a login or a logout has changed the answer.
*/
applyProfile(resp) {
if (!resp?.authenticated) {
this.setToGuest()
return
}
this.$patch({
/*
Kept, rather than left at the guest id this store starts with. Nothing used to read it
while logged in, so nothing noticed -- but a live editing session identifies its
participants by it, and every one of them claiming the guest id makes a roomful of
people look like one person wearing the same colour.
*/
id: resp.id,
name: resp.name || 'Unknown User',
email: resp.email,
hasAvatar: resp.hasAvatar ?? false,
location: resp.location || '',
jobTitle: resp.jobTitle || '',
pronouns: resp.pronouns || '',
timezone: resp.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || '',
dateFormat: resp.dateFormat || '',
timeFormat: resp.timeFormat || '12h',
appearance: resp.appearance || 'site',
cvd: resp.cvd || 'none',
permissions: resp.permissions || [],
authenticated: true,
profileLoaded: true
})
},
async logout() {
const siteStore = useSiteStore()
let redirect = '/'
@ -145,11 +154,16 @@ export const useUserStore = defineStore('user', {
appearance: 'site',
cvd: 'none',
permissions: [],
// -> Page permissions are only refetched on the next navigation, so leaving them would keep
// edit buttons on screen for a user who is no longer logged in
// -> Page permissions arrive with the page, so leaving them would keep edit buttons on screen
// for a user who is no longer logged in until they navigate
pagePermissions: [],
authenticated: false,
profileLoaded: false
/*
Loaded, not unknown: being a guest IS an answer, and this is where it is recorded whether
it came back from `bootstrap` or from logging out. Left false, every navigation would ask
the server who this is all over again, and every reader of a public wiki is a guest.
*/
profileLoaded: true
})
},
getAccessibleColor(base, hexBase) {

Loading…
Cancel
Save