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:
@@ -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 */ }
|
||||
})
|
||||
|
||||
|
||||
@@ -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/')
|
||||
|
||||
@@ -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 */ })
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(/\.[^.]+$/, '')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */ })
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user