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.
This commit is contained in:
2026-09-18 20:14:52 +00:00
parent e0c1b37c0b
commit 4bccf6855e
13 changed files with 80 additions and 60 deletions
+1
View File
@@ -20,6 +20,7 @@
"codemirror": "^6.0.2",
"country-flag-icons": "^1.6.20",
"overlayscrollbars": "^2.16.0",
"paskia": "^2.1.0",
"pinia": "^4.0.3",
"transliteration": "^2.6.1",
"vue": "^3.5.26",
+2 -2
View File
@@ -5,6 +5,7 @@
// visit/crawler tables. Read-only.
// See docs/analytics.md for the data format.
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { apiJson } from 'paskia'
import {
RANGES,
rangeWindow,
@@ -115,8 +116,7 @@ onMounted(async () => {
// 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.
try {
const res = await fetch('/_api/pages')
if (res.ok) pageTree.value = await res.json()
pageTree.value = await apiJson('/_api/pages')
} 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 { reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
import { dropPageCache, loadPlain, runScripts } from './swapdoc'
import { apiFetch, apiJson } from 'paskia'
const props = defineProps({
pagePath: { type: String, default: '' },
@@ -131,7 +132,7 @@ function onEditorShown() {
async function loadSettings() {
try {
const s = await (await fetch('/_api/settings')).json()
const s = await apiJson('/_api/settings')
theme.value = s.theme || ''
bannerDesigns.value = s.banner_designs || []
} catch { /* keep default */ }
@@ -202,7 +203,7 @@ async function uploadBannerMedia(file) {
// Banner media goes to the shared content store, like article images.
if (!file || !/^(image|video)\//.test(file.type)) return
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
const { path: stored } = await res.json()
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 { editorLang, pagePrimary } from './editorLang'
import { loadPlain, setLangOverride } from './swapdoc'
import { apiJson } from 'paskia'
const props = defineProps({
pagePath: { type: String, default: '' },
@@ -105,7 +106,7 @@ onMounted(() => {
// unknown; the page/structure tabs refine pagePrimary per page as they
// learn it (their knowledge is strictly better).
openShell()
fetch('/_api/settings').then((r) => r.json()).then((s) => {
apiJson('/_api/settings').then((s) => {
if (!pagePrimary.value) pagePrimary.value = s.primary_lang || 'en'
}).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 { copyList } from './analytics/format.js'
import { dropPageCache } from './swapdoc'
import { apiFetch, apiJson } from 'paskia'
defineProps({ pagePath: { type: String, default: '' } })
// close/path-change are wired by EditorShell; this tab never emits them.
@@ -65,7 +66,7 @@ function onEditorShown() {
onMounted(async () => {
addEventListener('pagerite:editor-shown', onEditorShown)
try {
const s = await (await fetch('/_api/settings')).json()
const s = await apiJson('/_api/settings')
selected.value = new Set(s.translate_langs || [])
keyUrls.value = Object.entries(s.translate_keys || {})
.map(([key, name]) => ({ key, name, url: wsUrl(key) }))
@@ -80,8 +81,8 @@ async function toggle(code) {
else next.add(code)
selected.value = next
try {
const s = await (await fetch('/_api/settings')).json()
const res = await fetch('/_api/settings', {
const s = await apiJson('/_api/settings')
const res = await apiFetch('/_api/settings', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ...s, translate_langs: [...next] }),
@@ -105,7 +106,7 @@ async function refresh() {
if (refreshing.value) return
refreshing.value = true
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'
if (res.ok) dropPageCache()
} catch {
@@ -122,8 +123,8 @@ async function refresh() {
// confirmation.
async function saveKeys() {
try {
const s = await (await fetch('/_api/settings')).json()
const res = await fetch('/_api/settings', {
const s = await apiJson('/_api/settings')
const res = await apiFetch('/_api/settings', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
+3 -2
View File
@@ -27,6 +27,7 @@
// was loaded in.
import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { usePopup } from './dropdown'
import { apiFetch } from 'paskia'
import { EditorView, basicSetup } from 'codemirror'
import { Compartment, EditorState } from '@codemirror/state'
import { keymap } from '@codemirror/view'
@@ -192,7 +193,7 @@ function save() {
}
// Empty text means delete — an explicit choice made here, in the page
// 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'
if (res.ok) stashes.delete(stashKey(path.value, lang.value))
})
@@ -242,7 +243,7 @@ function close() {
async function uploadImage(file) {
if (!file) return
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) {
const { path: stored } = await res.json()
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 { cmHighlight, cmTheme } from './cmtheme'
import { dropPageCache, loadPlain, runScripts } from './swapdoc'
import { apiFetch, apiJson } from 'paskia'
const props = defineProps({
pagePath: { type: String, default: '' },
@@ -73,7 +74,7 @@ function themeLabel(t) {
async function loadSettings() {
try {
const s = await (await fetch('/_api/settings')).json()
const s = await apiJson('/_api/settings')
brand.value = s.brand
brandHtml.value = s.brand_html || ''
setBrandDocument(brandHtml.value)
@@ -123,7 +124,7 @@ function applyFavicon(url) {
async function uploadFavicon(file) {
if (!file || !file.type.startsWith('image/')) return
const res = await fetch('/_api/settings/favicon', {
const res = await apiFetch('/_api/settings/favicon', {
method: 'PUT',
headers: { 'x-filename': file.name.replace(/[^\w.-]/g, '-') },
body: file,
@@ -198,7 +199,7 @@ function onBrandHtmlInput() {
async function uploadBrandMedia(file) {
if (!file || !/^(image|video)\//.test(file.type)) return
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
const { path: stored } = await res.json()
const tag = file.type.startsWith('video/')
@@ -245,7 +246,7 @@ function onEditorShown() {
}
async function saveSettings(opts = {}) {
const res = await fetch('/_api/settings', {
const res = await apiFetch('/_api/settings', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
@@ -275,7 +276,7 @@ async function onThemeChange() {
const url = `/_themes/${theme.value}/theme.css`
if (theme.value) {
if (el?.tagName === 'STYLE') {
el.textContent = await (await fetch(url)).text()
el.textContent = await (await apiFetch(url)).text()
} else if (el) {
el.href = url
} else if (import.meta.env.DEV) {
@@ -296,7 +297,7 @@ async function onThemeChange() {
// Prod: inline <style>, fetched from the backend-served URL.
el = document.createElement('style')
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
?? document.getElementById('pagerite-banner')
?? document.getElementById('pagerite-user')
@@ -318,7 +319,7 @@ async function onTransitionChange() {
let el = document.getElementById('pagerite-transition')
const url = `/_themes/${transition.value}/transition.css`
if (el?.tagName === 'STYLE') {
el.textContent = await (await fetch(url)).text()
el.textContent = await (await apiFetch(url)).text()
} else if (el) {
el.href = url
} else {
@@ -330,7 +331,7 @@ async function onTransitionChange() {
el.href = url
} else {
el = document.createElement('style')
el.textContent = await (await fetch(url)).text()
el.textContent = await (await apiFetch(url)).text()
}
el.id = 'pagerite-transition'
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 { editorLang, pagePrimary } from './editorLang'
import { dropPageCache, loadPlain } from './swapdoc'
import { apiFetch, apiJson } from 'paskia'
const props = defineProps({
pagePath: { type: String, default: '' },
@@ -171,7 +172,7 @@ async function commitPending() {
const loc = locatePending(tree.value, '')
const parentPath = loc?.parentPath ?? ''
const newPath = parentPath ? `${parentPath}/${slug}` : slug
const res = await fetch(`/_api/pages/${newPath}`, {
const res = await apiFetch(`/_api/pages/${newPath}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
@@ -220,7 +221,7 @@ function findNode(nodes, p) {
async function refreshPages() {
try {
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
// current page's (the shell pins the preview by it on '' selection).
pagePrimary.value = findNode(tree.value, path.value)?.primary || 'en'
@@ -235,7 +236,7 @@ async function errorDetail(res) {
}
async function postStructure(op) {
const res = await fetch('/_api/structure', {
const res = await apiFetch('/_api/structure', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(op),
@@ -308,7 +309,7 @@ async function commitSlug(node, ev) {
// Deletion is immediate, no confirmation.
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) {
saveError.value = ''
refreshPages()
@@ -346,7 +347,7 @@ onMounted(() => {
refreshPages()
addEventListener('pagerite:editor-shown', onEditorShown)
// 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'
siteLangs.value = s.translate_langs || []
}).catch(() => { /* no strip */ })
+3 -3
View File
@@ -801,11 +801,11 @@ article h2 .edit-section {
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
corner and inherit its reset; keep only their text-shadow tweak. */
.editor-pens a.login-link,
.editor-pens a.profile-link {
.editor-pens .login-link,
.editor-pens .profile-link {
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.
import { OverlayScrollbars } from "overlayscrollbars";
import "overlayscrollbars/overlayscrollbars.css";
import { profile, apiJson, fetchJson } from "paskia";
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.
//
// 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,
// 🔐 profile when logged in. Normal navigation: Paskia does not support
// being iframed, and history.back() returns to the page as-is (the
// pageshow handler below re-probes auth to refresh the pens).
// corner gets an auth button — 🔑 log in for anonymous visitors,
// 🔐 profile when logged in. The click opens paskia-js's profile() dialog
// (an iframe overlay; Paskia does not support being iframed by others, but
// 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 isAdmin = false;
let authReady = false;
@@ -163,13 +165,21 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
}
}
function makeAuthLink(admin) {
const a = document.createElement("a");
a.className = (admin ? "profile-link" : "login-link") + " icon-btn";
a.href = "/auth/";
a.title = admin ? "profile" : "log in";
a.textContent = admin ? "\u{1F510}" : "\u{1F511}";
return a;
function makeAuthButton(admin) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = (admin ? "profile-link" : "login-link") + " icon-btn";
btn.title = admin ? "profile" : "log in";
btn.textContent = admin ? "\u{1F510}" : "\u{1F511}";
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
@@ -218,7 +228,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
pens.append(a);
pens.append(makePen("site"));
}
if (ssoAvailable) pens.append(makeAuthLink(isAdmin));
if (ssoAvailable) pens.append(makeAuthButton(isAdmin));
if (!pens.firstElementChild) pens.remove();
}
if (canEdit && !onAnalytics) injectPagePen();
@@ -239,20 +249,24 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
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 {
const ssoRes = await fetch("/auth/api/settings");
ssoAvailable = ssoRes.ok;
await fetchJson("/auth/api/settings");
ssoAvailable = true;
} catch {
ssoAvailable = false;
}
// Check whether the current session has pagerite:admin.
isAdmin = false;
try {
isAdmin = (await fetch("/_api/settings")).status === 200;
await fetchJson("/_api/settings");
isAdmin = true;
} catch {
// No auth proxy / dev.
// Anonymous, no pagerite:admin, or no auth proxy / dev.
}
if (isAdmin) {
@@ -1063,6 +1077,10 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
// Checkboxes in the rendered article are live: toggling them edits the
// Markdown source. If the page editor is open, its CodeMirror document is
// 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) {
const editor = window.__pageritePageEditor;
const pagePath = editor ? editor.path() : currentPath;
@@ -1071,16 +1089,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
try {
const body = { path, index };
if (editor) body.markdown = editor.getMarkdown();
const res = await fetch("/_api/toggle-task", {
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();
const { markdown } = await apiJson("/_api/toggle-task", { method: "POST", body });
if (editor) editor.setMarkdown(markdown);
} catch {
checkbox.checked = originalChecked;
+3 -1
View File
@@ -3,6 +3,8 @@
// Used by BannerEditor (banner design changes), SiteEditor (theme changes)
// and StructureEditor (tree navigation).
import { apiFetch } from 'paskia'
// 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
// pages (theme, headings, structure, banner, etc.). The cache is rebuilt by
@@ -139,7 +141,7 @@ export async function loadPlain(p) {
let html
try {
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') || ''
if (!type.includes('text/html')) return null
if (res.redirected) finalUrl = res.url