Compare commits

..
1 Commits
Author SHA1 Message Date
LeoVasanko 4bccf6855e Auth via paskia-js: profile() dialog, apiFetch/apiJson in admin components
- Banner-corner auth link is now a button opening paskia-js's profile()
  dialog (handles login too); auth re-probed when the dialog closes.
- Editor/analytics components call /_api via apiFetch/apiJson: an
  expired session opens the login dialog and the request retries.
- pagerite.js: task-checkbox toggle uses apiJson (explicit edit attempt,
  reverts on any failure incl. cancelled login); auth probes use
  fetchJson (never a dialog); page-cache/navigation stay on plain fetch.
- Add the paskia npm dependency; document the convention.
2026-09-18 20:14:52 +00:00
13 changed files with 80 additions and 60 deletions
+1 -1
View File
@@ -67,5 +67,5 @@ Server run by CLI entry point `uv run pagerite` (no auto reloads, build needed).
- Keep dependencies minimal; add via `uv add` and mention it. - Keep dependencies minimal; add via `uv add` and mention it.
- The public URL space belongs to content (pretty slugs at root). Reserve only `/_` for the machinery (`/_api/`, `/_f/`, `/_assets/`), plus `/favicon.ico` (backend redirect to the configured site icon). Slugs are lowercase ASCII letters, digits, hyphens and underscores `[a-z0-9_-]` (the site editor filters input live via `slugify.js`, built on the `transliteration` npm package — unicode folds to ASCII, spaces become hyphens; an empty slug on a new page is derived from its title), may not begin with `_` or `.`, and such URLs are never looked up as content. - The public URL space belongs to content (pretty slugs at root). Reserve only `/_` for the machinery (`/_api/`, `/_f/`, `/_assets/`), plus `/favicon.ico` (backend redirect to the configured site icon). Slugs are lowercase ASCII letters, digits, hyphens and underscores `[a-z0-9_-]` (the site editor filters input live via `slugify.js`, built on the `transliteration` npm package — unicode folds to ASCII, spaces become hyphens; an empty slug on a new page is derived from its title), may not begin with `_` or `.`, and such URLs are never looked up as content.
- No auth in core code; the SSO/reverse proxy gates all of `/_api` (forward-auth) and owns `/auth/` (login/logout, session validation). Pages render identically for everyone; pagerite.js adds the editing UI only after the auth server validates the session. The one keyed exception is `/_translate/{key}` (translator service; `Data.translate_keys`, see docs/localization.md). - No auth in core code; the SSO/reverse proxy gates all of `/_api` (forward-auth) and owns `/auth/` (login/logout, session validation). Pages render identically for everyone; pagerite.js adds the editing UI only after the auth server validates the session. The one keyed exception is `/_translate/{key}` (translator service; `Data.translate_keys`, see docs/localization.md). Admin components use the paskia npm package's `apiFetch`/`apiJson` for `/_api` calls (login dialog + retry on expired sessions); pagerite.js uses `apiJson` only for the task-checkbox toggle (an explicit edit attempt) and `fetchJson` for its auth probes — never `apiFetch` there, so anonymous visitors never get a login popup.
- Update the relevant MarkDown files when architecture, tooling, or conventions change. - Update the relevant MarkDown files when architecture, tooling, or conventions change.
+3 -1
View File
@@ -10,7 +10,9 @@ Vue editor app entry, mounts the tabbed `EditorShell`. See `docs/editing.md` for
Public page entry; runs fetch-navigation (backed by an in-memory page cache: every visible internal link is fetched once at load and clicks are then served from JS with no fetch — the current page itself is not refetched, it enters the cache when navigated to — and the editors' `loadPlain` keeps the cache current via a `pagerite:page-fetched` event; articles are `cache-control: no-cache` on the wire). Editors can drop the entire cache with the `pagerite:drop-page-cache` event when site-wide or page changes (theme, headings, structure, banners, etc.) invalidate the cached HTML of other pages; `main.js` triggers a fresh `pagerite:preload-pages` pass when the editor panel closes so navigation is fast again. Navigation that starts while the editor is open bypasses the cache and fetches the target page on demand. Also runs scroll-reveal, a scroll-driven section hash (the location hash tracks the h1/h2 above the viewport middle via replaceState — removed above the first tagged heading and at the very top, never set on unscrollable pages), OverlayScrollbars on `document.body` (floating, auto-hiding scrollbars that never reserve layout space or shift the page when appearing; native scroll APIs like `window.scrollTo` keep working; themed via the `--os-*` variables in pagerite.css), brand shrink-to-fit (the themed size is the maximum; JS reduces the font-size so a long brand or narrow viewport still fits one line), nav condense-to-fit (the top nav stays on one row: link gaps shrink first, then the side padding, then the font size; `flex-wrap: wrap` remains the no-JS fallback), code copy buttons, click-to-enlarge on article figure images (a full-viewport lightbox with the caption, closed by click or Esc), and the auth check. Public page entry; runs fetch-navigation (backed by an in-memory page cache: every visible internal link is fetched once at load and clicks are then served from JS with no fetch — the current page itself is not refetched, it enters the cache when navigated to — and the editors' `loadPlain` keeps the cache current via a `pagerite:page-fetched` event; articles are `cache-control: no-cache` on the wire). Editors can drop the entire cache with the `pagerite:drop-page-cache` event when site-wide or page changes (theme, headings, structure, banners, etc.) invalidate the cached HTML of other pages; `main.js` triggers a fresh `pagerite:preload-pages` pass when the editor panel closes so navigation is fast again. Navigation that starts while the editor is open bypasses the cache and fetches the target page on demand. Also runs scroll-reveal, a scroll-driven section hash (the location hash tracks the h1/h2 above the viewport middle via replaceState — removed above the first tagged heading and at the very top, never set on unscrollable pages), OverlayScrollbars on `document.body` (floating, auto-hiding scrollbars that never reserve layout space or shift the page when appearing; native scroll APIs like `window.scrollTo` keep working; themed via the `--os-*` variables in pagerite.css), brand shrink-to-fit (the themed size is the maximum; JS reduces the font-size so a long brand or narrow viewport still fits one line), nav condense-to-fit (the top nav stays on one row: link gaps shrink first, then the side padding, then the font size; `flex-wrap: wrap` remains the no-JS fallback), code copy buttons, click-to-enlarge on article figure images (a full-viewport lightbox with the caption, closed by click or Esc), and the auth check.
It first probes `GET /auth/api/settings` to detect whether Paskia SSO is available, then `GET /_api/settings` to learn the current session's admin status. The same reverse proxy that gates `/_api` returns 401 for anonymous users, 403 for users without the admin permission, and 200 for admins. When Paskia is detected, a login link (anonymous) or profile link (logged in) is shown in the banner corner; both are plain `<a href="/auth/">` links (Paskia does not support being iframed, so we navigate normally), and a `pageshow` handler re-probes auth when history navigation restores a cached page. Admins also get the page/banner edit pens and a site-settings pen, plus a `modulepreload` warm-up of the editor bundle (the hashed asset is immutable, so it costs nothing). If no Paskia SSO is detected (dev/no proxy), editing is left open. Pages themselves render identically for everyone; the real gate is the auth proxy in front of all of `/_api`. It first probes `GET /auth/api/settings` to detect whether Paskia SSO is available, then `GET /_api/settings` to learn the current session's admin status. The same reverse proxy that gates `/_api` returns 401 for anonymous users, 403 for users without the admin permission, and 200 for admins. When Paskia is detected, a login button (anonymous) or profile button (logged in) is shown in the banner corner; clicking it opens the paskia-js `profile()` dialog (an iframe overlay served by Paskia itself, which also runs the login flow), and auth is re-probed when the dialog closes. A `pageshow` handler also re-probes auth when history navigation restores a cached page. Admins also get the page/banner edit pens and a site-settings pen, plus a `modulepreload` warm-up of the editor bundle (the hashed asset is immutable, so it costs nothing). If no Paskia SSO is detected (dev/no proxy), editing is left open. Pages themselves render identically for everyone; the real gate is the auth proxy in front of all of `/_api`.
The editor/analytics components (everything except pagerite.js and main.js) make their `/_api` calls through paskia-js's `apiFetch`/`apiJson` instead of plain `fetch`: when a call gets a 401/403 carrying an `auth.iframe` hint (expired session), the login dialog opens in place and the request retries transparently after authentication. pagerite.js uses `apiJson` only for the task-checkbox toggle (ticking a box is an explicit edit attempt, so a login dialog is welcome there; any failure — including a cancelled login — reverts the checkbox) and `fetchJson` for its auth probes (plain fetch with JSON handling and errors on non-OK, never a dialog); page-cache and navigation fetches stay on plain `fetch`, so anonymous visitors never get a login popup uninvited.
Asset wiring differs by mode. In dev the backend links the Vite dev-server URLs (`pagerite:editor-src`/`-css`/`pagerite:analytics-src` meta tags, `<link>` stylesheets) and Vite injects the entry CSS from JS for hot reloads. In production there are no pagerite meta tags: all page assets are inlined into the document — stylesheets as `<style>` elements in `<head>` (fixed order: base, theme, banner design, page transition, entry sheets, custom CSS last), module scripts as inline `<script>`s at the end of the body (relative chunk imports are rewritten to absolute `/_assets/` paths) — and the on-demand bundles' URLs ride in a `<script type="application/json" id="pagerite-assets">` config. The editor bundle always stays external, imported on demand when a pen is opened. Every stylesheet element carries a stable id so fetch-navigation and the site editor can sync `<head>` positionally across swaps (the analytics sheet exists on `/_a` only and is added/removed as you navigate). The analytics entry is inlined into the `/_a` page itself; pagerite.js re-creates that script element after fetch-navigating there (inline scripts don't execute on a DOM swap) and calls the module's exposed unmount before swapping away. Asset wiring differs by mode. In dev the backend links the Vite dev-server URLs (`pagerite:editor-src`/`-css`/`pagerite:analytics-src` meta tags, `<link>` stylesheets) and Vite injects the entry CSS from JS for hot reloads. In production there are no pagerite meta tags: all page assets are inlined into the document — stylesheets as `<style>` elements in `<head>` (fixed order: base, theme, banner design, page transition, entry sheets, custom CSS last), module scripts as inline `<script>`s at the end of the body (relative chunk imports are rewritten to absolute `/_assets/` paths) — and the on-demand bundles' URLs ride in a `<script type="application/json" id="pagerite-assets">` config. The editor bundle always stays external, imported on demand when a pen is opened. Every stylesheet element carries a stable id so fetch-navigation and the site editor can sync `<head>` positionally across swaps (the analytics sheet exists on `/_a` only and is added/removed as you navigate). The analytics entry is inlined into the `/_a` page itself; pagerite.js re-creates that script element after fetch-navigating there (inline scripts don't execute on a DOM swap) and calls the module's exposed unmount before swapping away.
+1
View File
@@ -20,6 +20,7 @@
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"country-flag-icons": "^1.6.20", "country-flag-icons": "^1.6.20",
"overlayscrollbars": "^2.16.0", "overlayscrollbars": "^2.16.0",
"paskia": "^2.1.0",
"pinia": "^4.0.3", "pinia": "^4.0.3",
"transliteration": "^2.6.1", "transliteration": "^2.6.1",
"vue": "^3.5.26", "vue": "^3.5.26",
+2 -2
View File
@@ -5,6 +5,7 @@
// visit/crawler tables. Read-only. // visit/crawler tables. Read-only.
// See docs/analytics.md for the data format. // See docs/analytics.md for the data format.
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { apiJson } from 'paskia'
import { import {
RANGES, RANGES,
rangeWindow, rangeWindow,
@@ -115,8 +116,7 @@ onMounted(async () => {
// The site tree for the transition map (all pages in menu order). Not // The site tree for the transition map (all pages in menu order). Not
// fatal: without it the map just narrows to pages seen in transitions. // fatal: without it the map just narrows to pages seen in transitions.
try { try {
const res = await fetch('/_api/pages') pageTree.value = await apiJson('/_api/pages')
if (res.ok) pageTree.value = await res.json()
} catch { /* map just narrows to pages seen in transitions */ } } catch { /* map just narrows to pages seen in transitions */ }
}) })
+3 -2
View File
@@ -11,6 +11,7 @@ import { cmHighlight, cmTheme } from './cmtheme'
import ConnNote from './ConnNote.vue' import ConnNote from './ConnNote.vue'
import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect' import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
import { dropPageCache, loadPlain, runScripts } from './swapdoc' import { dropPageCache, loadPlain, runScripts } from './swapdoc'
import { apiFetch, apiJson } from 'paskia'
const props = defineProps({ const props = defineProps({
pagePath: { type: String, default: '' }, pagePath: { type: String, default: '' },
@@ -131,7 +132,7 @@ function onEditorShown() {
async function loadSettings() { async function loadSettings() {
try { try {
const s = await (await fetch('/_api/settings')).json() const s = await apiJson('/_api/settings')
theme.value = s.theme || '' theme.value = s.theme || ''
bannerDesigns.value = s.banner_designs || [] bannerDesigns.value = s.banner_designs || []
} catch { /* keep default */ } } catch { /* keep default */ }
@@ -202,7 +203,7 @@ async function uploadBannerMedia(file) {
// Banner media goes to the shared content store, like article images. // Banner media goes to the shared content store, like article images.
if (!file || !/^(image|video)\//.test(file.type)) return if (!file || !/^(image|video)\//.test(file.type)) return
const name = file.name.replace(/[^\w.-]/g, '-') const name = file.name.replace(/[^\w.-]/g, '-')
const res = await fetch(`/_api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file }) const res = await apiFetch(`/_api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file })
if (!res.ok) return if (!res.ok) return
const { path: stored } = await res.json() const { path: stored } = await res.json()
const tag = file.type.startsWith('video/') const tag = file.type.startsWith('video/')
+2 -1
View File
@@ -10,6 +10,7 @@ import StructureEditor from './StructureEditor.vue'
import LocalizationEditor from './LocalizationEditor.vue' import LocalizationEditor from './LocalizationEditor.vue'
import { editorLang, pagePrimary } from './editorLang' import { editorLang, pagePrimary } from './editorLang'
import { loadPlain, setLangOverride } from './swapdoc' import { loadPlain, setLangOverride } from './swapdoc'
import { apiJson } from 'paskia'
const props = defineProps({ const props = defineProps({
pagePath: { type: String, default: '' }, pagePath: { type: String, default: '' },
@@ -105,7 +106,7 @@ onMounted(() => {
// unknown; the page/structure tabs refine pagePrimary per page as they // unknown; the page/structure tabs refine pagePrimary per page as they
// learn it (their knowledge is strictly better). // learn it (their knowledge is strictly better).
openShell() openShell()
fetch('/_api/settings').then((r) => r.json()).then((s) => { apiJson('/_api/settings').then((s) => {
if (!pagePrimary.value) pagePrimary.value = s.primary_lang || 'en' if (!pagePrimary.value) pagePrimary.value = s.primary_lang || 'en'
}).catch(() => { /* keep the fallback */ }) }).catch(() => { /* keep the fallback */ })
}) })
+7 -6
View File
@@ -17,6 +17,7 @@ import { computed, onActivated, onMounted, onUnmounted, ref } from 'vue'
import { LANG_GROUPS, TRANSLATABLE, flagFor, langName } from './langs' import { LANG_GROUPS, TRANSLATABLE, flagFor, langName } from './langs'
import { copyList } from './analytics/format.js' import { copyList } from './analytics/format.js'
import { dropPageCache } from './swapdoc' import { dropPageCache } from './swapdoc'
import { apiFetch, apiJson } from 'paskia'
defineProps({ pagePath: { type: String, default: '' } }) defineProps({ pagePath: { type: String, default: '' } })
// close/path-change are wired by EditorShell; this tab never emits them. // close/path-change are wired by EditorShell; this tab never emits them.
@@ -65,7 +66,7 @@ function onEditorShown() {
onMounted(async () => { onMounted(async () => {
addEventListener('pagerite:editor-shown', onEditorShown) addEventListener('pagerite:editor-shown', onEditorShown)
try { try {
const s = await (await fetch('/_api/settings')).json() const s = await apiJson('/_api/settings')
selected.value = new Set(s.translate_langs || []) selected.value = new Set(s.translate_langs || [])
keyUrls.value = Object.entries(s.translate_keys || {}) keyUrls.value = Object.entries(s.translate_keys || {})
.map(([key, name]) => ({ key, name, url: wsUrl(key) })) .map(([key, name]) => ({ key, name, url: wsUrl(key) }))
@@ -80,8 +81,8 @@ async function toggle(code) {
else next.add(code) else next.add(code)
selected.value = next selected.value = next
try { try {
const s = await (await fetch('/_api/settings')).json() const s = await apiJson('/_api/settings')
const res = await fetch('/_api/settings', { const res = await apiFetch('/_api/settings', {
method: 'PUT', method: 'PUT',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ...s, translate_langs: [...next] }), body: JSON.stringify({ ...s, translate_langs: [...next] }),
@@ -105,7 +106,7 @@ async function refresh() {
if (refreshing.value) return if (refreshing.value) return
refreshing.value = true refreshing.value = true
try { try {
const res = await fetch('/_api/translations', { method: 'DELETE' }) const res = await apiFetch('/_api/translations', { method: 'DELETE' })
saveError.value = res.ok ? '' : '⚠️ translations could not be refreshed' saveError.value = res.ok ? '' : '⚠️ translations could not be refreshed'
if (res.ok) dropPageCache() if (res.ok) dropPageCache()
} catch { } catch {
@@ -122,8 +123,8 @@ async function refresh() {
// confirmation. // confirmation.
async function saveKeys() { async function saveKeys() {
try { try {
const s = await (await fetch('/_api/settings')).json() const s = await apiJson('/_api/settings')
const res = await fetch('/_api/settings', { const res = await apiFetch('/_api/settings', {
method: 'PUT', method: 'PUT',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
+3 -2
View File
@@ -27,6 +27,7 @@
// was loaded in. // was loaded in.
import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { usePopup } from './dropdown' import { usePopup } from './dropdown'
import { apiFetch } from 'paskia'
import { EditorView, basicSetup } from 'codemirror' import { EditorView, basicSetup } from 'codemirror'
import { Compartment, EditorState } from '@codemirror/state' import { Compartment, EditorState } from '@codemirror/state'
import { keymap } from '@codemirror/view' import { keymap } from '@codemirror/view'
@@ -192,7 +193,7 @@ function save() {
} }
// Empty text means delete — an explicit choice made here, in the page // Empty text means delete — an explicit choice made here, in the page
// editor; the save APIs (REST PUT / WS save) never delete on empty. // editor; the save APIs (REST PUT / WS save) never delete on empty.
return fetch(`/_api/pages/${path.value}`, { method: 'DELETE' }).then((res) => { return apiFetch(`/_api/pages/${path.value}`, { method: 'DELETE' }).then((res) => {
saveError.value = res.ok ? '' : '⚠️ changes could not be saved' saveError.value = res.ok ? '' : '⚠️ changes could not be saved'
if (res.ok) stashes.delete(stashKey(path.value, lang.value)) if (res.ok) stashes.delete(stashKey(path.value, lang.value))
}) })
@@ -242,7 +243,7 @@ function close() {
async function uploadImage(file) { async function uploadImage(file) {
if (!file) return if (!file) return
const name = file.name.replace(/[^\w.-]/g, '-') const name = file.name.replace(/[^\w.-]/g, '-')
const res = await fetch(`/_api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file }) const res = await apiFetch(`/_api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file })
if (res.ok) { if (res.ok) {
const { path: stored } = await res.json() const { path: stored } = await res.json()
const alt = name.replace(/\.[^.]+$/, '') const alt = name.replace(/\.[^.]+$/, '')
+9 -8
View File
@@ -11,6 +11,7 @@ import { css } from '@codemirror/lang-css'
import { html } from '@codemirror/lang-html' import { html } from '@codemirror/lang-html'
import { cmHighlight, cmTheme } from './cmtheme' import { cmHighlight, cmTheme } from './cmtheme'
import { dropPageCache, loadPlain, runScripts } from './swapdoc' import { dropPageCache, loadPlain, runScripts } from './swapdoc'
import { apiFetch, apiJson } from 'paskia'
const props = defineProps({ const props = defineProps({
pagePath: { type: String, default: '' }, pagePath: { type: String, default: '' },
@@ -73,7 +74,7 @@ function themeLabel(t) {
async function loadSettings() { async function loadSettings() {
try { try {
const s = await (await fetch('/_api/settings')).json() const s = await apiJson('/_api/settings')
brand.value = s.brand brand.value = s.brand
brandHtml.value = s.brand_html || '' brandHtml.value = s.brand_html || ''
setBrandDocument(brandHtml.value) setBrandDocument(brandHtml.value)
@@ -123,7 +124,7 @@ function applyFavicon(url) {
async function uploadFavicon(file) { async function uploadFavicon(file) {
if (!file || !file.type.startsWith('image/')) return if (!file || !file.type.startsWith('image/')) return
const res = await fetch('/_api/settings/favicon', { const res = await apiFetch('/_api/settings/favicon', {
method: 'PUT', method: 'PUT',
headers: { 'x-filename': file.name.replace(/[^\w.-]/g, '-') }, headers: { 'x-filename': file.name.replace(/[^\w.-]/g, '-') },
body: file, body: file,
@@ -198,7 +199,7 @@ function onBrandHtmlInput() {
async function uploadBrandMedia(file) { async function uploadBrandMedia(file) {
if (!file || !/^(image|video)\//.test(file.type)) return if (!file || !/^(image|video)\//.test(file.type)) return
const name = file.name.replace(/[^\w.-]/g, '-') const name = file.name.replace(/[^\w.-]/g, '-')
const res = await fetch(`/_api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file }) const res = await apiFetch(`/_api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file })
if (!res.ok) return if (!res.ok) return
const { path: stored } = await res.json() const { path: stored } = await res.json()
const tag = file.type.startsWith('video/') const tag = file.type.startsWith('video/')
@@ -245,7 +246,7 @@ function onEditorShown() {
} }
async function saveSettings(opts = {}) { async function saveSettings(opts = {}) {
const res = await fetch('/_api/settings', { const res = await apiFetch('/_api/settings', {
method: 'PUT', method: 'PUT',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -275,7 +276,7 @@ async function onThemeChange() {
const url = `/_themes/${theme.value}/theme.css` const url = `/_themes/${theme.value}/theme.css`
if (theme.value) { if (theme.value) {
if (el?.tagName === 'STYLE') { if (el?.tagName === 'STYLE') {
el.textContent = await (await fetch(url)).text() el.textContent = await (await apiFetch(url)).text()
} else if (el) { } else if (el) {
el.href = url el.href = url
} else if (import.meta.env.DEV) { } else if (import.meta.env.DEV) {
@@ -296,7 +297,7 @@ async function onThemeChange() {
// Prod: inline <style>, fetched from the backend-served URL. // Prod: inline <style>, fetched from the backend-served URL.
el = document.createElement('style') el = document.createElement('style')
el.id = 'pagerite-theme' el.id = 'pagerite-theme'
el.textContent = await (await fetch(url)).text() el.textContent = await (await apiFetch(url)).text()
const before = document.getElementById('pagerite-base')?.nextSibling const before = document.getElementById('pagerite-base')?.nextSibling
?? document.getElementById('pagerite-banner') ?? document.getElementById('pagerite-banner')
?? document.getElementById('pagerite-user') ?? document.getElementById('pagerite-user')
@@ -318,7 +319,7 @@ async function onTransitionChange() {
let el = document.getElementById('pagerite-transition') let el = document.getElementById('pagerite-transition')
const url = `/_themes/${transition.value}/transition.css` const url = `/_themes/${transition.value}/transition.css`
if (el?.tagName === 'STYLE') { if (el?.tagName === 'STYLE') {
el.textContent = await (await fetch(url)).text() el.textContent = await (await apiFetch(url)).text()
} else if (el) { } else if (el) {
el.href = url el.href = url
} else { } else {
@@ -330,7 +331,7 @@ async function onTransitionChange() {
el.href = url el.href = url
} else { } else {
el = document.createElement('style') el = document.createElement('style')
el.textContent = await (await fetch(url)).text() el.textContent = await (await apiFetch(url)).text()
} }
el.id = 'pagerite-transition' el.id = 'pagerite-transition'
const before = document.getElementById('pagerite-banner')?.nextSibling const before = document.getElementById('pagerite-banner')?.nextSibling
+6 -5
View File
@@ -22,6 +22,7 @@ import { slugify } from './slugify'
import { flagFor, langName, langSort } from './langs' import { flagFor, langName, langSort } from './langs'
import { editorLang, pagePrimary } from './editorLang' import { editorLang, pagePrimary } from './editorLang'
import { dropPageCache, loadPlain } from './swapdoc' import { dropPageCache, loadPlain } from './swapdoc'
import { apiFetch, apiJson } from 'paskia'
const props = defineProps({ const props = defineProps({
pagePath: { type: String, default: '' }, pagePath: { type: String, default: '' },
@@ -171,7 +172,7 @@ async function commitPending() {
const loc = locatePending(tree.value, '') const loc = locatePending(tree.value, '')
const parentPath = loc?.parentPath ?? '' const parentPath = loc?.parentPath ?? ''
const newPath = parentPath ? `${parentPath}/${slug}` : slug const newPath = parentPath ? `${parentPath}/${slug}` : slug
const res = await fetch(`/_api/pages/${newPath}`, { const res = await apiFetch(`/_api/pages/${newPath}`, {
method: 'PUT', method: 'PUT',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -220,7 +221,7 @@ function findNode(nodes, p) {
async function refreshPages() { async function refreshPages() {
try { try {
const q = lang.value ? `?lang=${lang.value}` : '' const q = lang.value ? `?lang=${lang.value}` : ''
tree.value = await (await fetch(`/_api/pages${q}`)).json() tree.value = await apiJson(`/_api/pages${q}`)
// The tree carries each node's resolved primary language: publish the // The tree carries each node's resolved primary language: publish the
// current page's (the shell pins the preview by it on '' selection). // current page's (the shell pins the preview by it on '' selection).
pagePrimary.value = findNode(tree.value, path.value)?.primary || 'en' pagePrimary.value = findNode(tree.value, path.value)?.primary || 'en'
@@ -235,7 +236,7 @@ async function errorDetail(res) {
} }
async function postStructure(op) { async function postStructure(op) {
const res = await fetch('/_api/structure', { const res = await apiFetch('/_api/structure', {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify(op), body: JSON.stringify(op),
@@ -308,7 +309,7 @@ async function commitSlug(node, ev) {
// Deletion is immediate, no confirmation. // Deletion is immediate, no confirmation.
async function removePage(node) { async function removePage(node) {
const res = await fetch(`/_api/pages/${node.path}`, { method: 'DELETE' }) const res = await apiFetch(`/_api/pages/${node.path}`, { method: 'DELETE' })
if (res.ok) { if (res.ok) {
saveError.value = '' saveError.value = ''
refreshPages() refreshPages()
@@ -346,7 +347,7 @@ onMounted(() => {
refreshPages() refreshPages()
addEventListener('pagerite:editor-shown', onEditorShown) addEventListener('pagerite:editor-shown', onEditorShown)
// The language strip: site primary + configured targets. // The language strip: site primary + configured targets.
fetch('/_api/settings').then((r) => r.json()).then((s) => { apiJson('/_api/settings').then((s) => {
primaryLang.value = s.primary_lang || 'en' primaryLang.value = s.primary_lang || 'en'
siteLangs.value = s.translate_langs || [] siteLangs.value = s.translate_langs || []
}).catch(() => { /* no strip */ }) }).catch(() => { /* no strip */ })
+3 -3
View File
@@ -801,11 +801,11 @@ article h2 .edit-section {
opacity: 0.35; opacity: 0.35;
} }
/* Login/profile links injected by pagerite.js when Paskia SSO is in use. /* Login/profile buttons injected by pagerite.js when Paskia SSO is in use.
They live inside the .editor-pens flex container in the banner's top-right They live inside the .editor-pens flex container in the banner's top-right
corner and inherit its reset; keep only their text-shadow tweak. */ corner and inherit its reset; keep only their text-shadow tweak. */
.editor-pens a.login-link, .editor-pens .login-link,
.editor-pens a.profile-link { .editor-pens .profile-link {
text-shadow: 0 0 0.1em black; text-shadow: 0 0 0.1em black;
} }
+37 -28
View File
@@ -6,6 +6,7 @@
// support from the article itself and are re-applied after each swap. // support from the article itself and are re-applied after each swap.
import { OverlayScrollbars } from "overlayscrollbars"; import { OverlayScrollbars } from "overlayscrollbars";
import "overlayscrollbars/overlayscrollbars.css"; import "overlayscrollbars/overlayscrollbars.css";
import { profile, apiJson, fetchJson } from "paskia";
import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
(() => { (() => {
@@ -101,10 +102,11 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
// 401/403 here, and a 200 means the permission is present. // 401/403 here, and a 200 means the permission is present.
// //
// When Paskia SSO is in use (probed via /auth/api/settings), the banner // When Paskia SSO is in use (probed via /auth/api/settings), the banner
// corner gets a plain link to /auth/ — 🔑 log in for anonymous visitors, // corner gets an auth button — 🔑 log in for anonymous visitors,
// 🔐 profile when logged in. Normal navigation: Paskia does not support // 🔐 profile when logged in. The click opens paskia-js's profile() dialog
// being iframed, and history.back() returns to the page as-is (the // (an iframe overlay; Paskia does not support being iframed by others, but
// pageshow handler below re-probes auth to refresh the pens). // serves this dialog itself), which handles the login flow too. On close we
// re-probe auth: login/logout inside the dialog changes the session.
let ssoAvailable = false; let ssoAvailable = false;
let isAdmin = false; let isAdmin = false;
let authReady = false; let authReady = false;
@@ -163,13 +165,21 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
} }
} }
function makeAuthLink(admin) { function makeAuthButton(admin) {
const a = document.createElement("a"); const btn = document.createElement("button");
a.className = (admin ? "profile-link" : "login-link") + " icon-btn"; btn.type = "button";
a.href = "/auth/"; btn.className = (admin ? "profile-link" : "login-link") + " icon-btn";
a.title = admin ? "profile" : "log in"; btn.title = admin ? "profile" : "log in";
a.textContent = admin ? "\u{1F510}" : "\u{1F511}"; btn.textContent = admin ? "\u{1F510}" : "\u{1F511}";
return a; btn.addEventListener("click", async () => {
// Resolves when the dialog closes ("login"/"logout"/"back"); whatever
// happened, the session may have changed — re-probe and re-render.
try {
await profile();
} catch { /* dialog closed without completing */ }
setupAuth();
});
return btn;
} }
// The banner top-right corner container: the language selector (first // The banner top-right corner container: the language selector (first
@@ -218,7 +228,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
pens.append(a); pens.append(a);
pens.append(makePen("site")); pens.append(makePen("site"));
} }
if (ssoAvailable) pens.append(makeAuthLink(isAdmin)); if (ssoAvailable) pens.append(makeAuthButton(isAdmin));
if (!pens.firstElementChild) pens.remove(); if (!pens.firstElementChild) pens.remove();
} }
if (canEdit && !onAnalytics) injectPagePen(); if (canEdit && !onAnalytics) injectPagePen();
@@ -239,20 +249,24 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
css: assets["pagerite:editor-css"], css: assets["pagerite:editor-css"],
}; };
// Detect whether Paskia SSO is available on this site. // Detect whether Paskia SSO is available on this site, and whether the
// current session has pagerite:admin. fetchJson (paskia-js) is plain
// fetch with JSON handling and an error on non-OK — it never opens the
// login dialog (that is apiFetch/apiJson's job), so these probes are
// safe to run for anonymous visitors.
try { try {
const ssoRes = await fetch("/auth/api/settings"); await fetchJson("/auth/api/settings");
ssoAvailable = ssoRes.ok; ssoAvailable = true;
} catch { } catch {
ssoAvailable = false; ssoAvailable = false;
} }
// Check whether the current session has pagerite:admin.
isAdmin = false; isAdmin = false;
try { try {
isAdmin = (await fetch("/_api/settings")).status === 200; await fetchJson("/_api/settings");
isAdmin = true;
} catch { } catch {
// No auth proxy / dev. // Anonymous, no pagerite:admin, or no auth proxy / dev.
} }
if (isAdmin) { if (isAdmin) {
@@ -1063,6 +1077,10 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
// Checkboxes in the rendered article are live: toggling them edits the // Checkboxes in the rendered article are live: toggling them edits the
// Markdown source. If the page editor is open, its CodeMirror document is // Markdown source. If the page editor is open, its CodeMirror document is
// updated directly; otherwise the server copy is toggled and saved. // updated directly; otherwise the server copy is toggled and saved.
// apiJson (paskia-js): a 401/403 from an expired session opens the login
// dialog and the toggle retries after auth — ticking a box is an explicit
// edit attempt. Any failure reverts the checkbox, including the user
// cancelling that dialog (AuthCancelledError).
async function toggleTask(checkbox, index) { async function toggleTask(checkbox, index) {
const editor = window.__pageritePageEditor; const editor = window.__pageritePageEditor;
const pagePath = editor ? editor.path() : currentPath; const pagePath = editor ? editor.path() : currentPath;
@@ -1071,16 +1089,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
try { try {
const body = { path, index }; const body = { path, index };
if (editor) body.markdown = editor.getMarkdown(); if (editor) body.markdown = editor.getMarkdown();
const res = await fetch("/_api/toggle-task", { const { markdown } = await apiJson("/_api/toggle-task", { method: "POST", body });
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const detail = await res.json().catch(() => ({}));
throw new Error(detail.detail || res.statusText);
}
const { markdown } = await res.json();
if (editor) editor.setMarkdown(markdown); if (editor) editor.setMarkdown(markdown);
} catch { } catch {
checkbox.checked = originalChecked; checkbox.checked = originalChecked;
+3 -1
View File
@@ -3,6 +3,8 @@
// Used by BannerEditor (banner design changes), SiteEditor (theme changes) // Used by BannerEditor (banner design changes), SiteEditor (theme changes)
// and StructureEditor (tree navigation). // and StructureEditor (tree navigation).
import { apiFetch } from 'paskia'
// Drop the public page runtime's in-memory prefetch cache. Editors call this // Drop the public page runtime's in-memory prefetch cache. Editors call this
// whenever a site-wide or page change invalidates the cached HTML of other // whenever a site-wide or page change invalidates the cached HTML of other
// pages (theme, headings, structure, banner, etc.). The cache is rebuilt by // pages (theme, headings, structure, banner, etc.). The cache is rebuilt by
@@ -139,7 +141,7 @@ export async function loadPlain(p) {
let html let html
try { try {
const pin = overrideLang || window.__pageriteLang const pin = overrideLang || window.__pageriteLang
const res = await fetch(pin ? `${finalUrl}?lang=${pin}` : finalUrl) const res = await apiFetch(pin ? `${finalUrl}?lang=${pin}` : finalUrl)
const type = res.headers.get('content-type') || '' const type = res.headers.get('content-type') || ''
if (!type.includes('text/html')) return null if (!type.includes('text/html')) return null
if (res.redirected) finalUrl = res.url if (res.redirected) finalUrl = res.url