Files
bindery/bindery/static/js/main.js
T
tim bb6c87cdd5 Let only admins change the global settings
BINDERY_ADMIN_USERS lists the admins of the cwa and proxy modes. Only an
admin can change the global settings and the global rules, and read or
change personal.ini. Without a list, each user is an admin.
2026-10-02 23:37:33 +02:00

200 lines
6.7 KiB
JavaScript

// Start-up, login, the header and the hash routes (#/story/3 and so on).
import { chapterPage } from './chapter.js';
import { startPolling, stopPolling } from './jobs.js';
import { api, fill, h, setStoryTitleHandler, setUnauthorizedHandler, showError } from './lib.js';
import { settingsPage } from './settings.js';
import { storiesPage } from './stories.js';
import { storyPage } from './story.js';
const ROUTES = [
[/^\/$/, storiesPage, []],
[/^\/story\/(\d+)$/, storyPage, ['storyId']],
[/^\/story\/(\d+)\/chapter\/(\d+)$/, chapterPage, ['storyId', 'chapterId']],
[/^\/settings$/, settingsPage, [], true],
];
const app = document.getElementById('bindery-app');
let auth = null;
let config = null;
let cleanup = null;
let loggedIn = false;
let nav = null;
let storyTab = null;
let storyTabShown = false;
// The server refuses the changes of a user who is not an admin. The interface only hides what that user cannot use.
const isAdmin = () => auth.admin !== false;
const currentPath = () => window.location.hash.replace(/^#/, '') || '/';
// The story of #/story/3 and of its chapter pages, such as #/story/3/chapter/5.
const currentStoryId = () => currentPath().match(/^\/story\/(\d+)(?:\/|$)/)?.[1];
// A story tab stays current on the chapter pages of its story.
function markCurrent(a, path) {
const target = a.getAttribute('href').replace(/^#/, '');
if (path === target || (target !== '/' && path.startsWith(`${target}/`))) a.setAttribute('aria-current', 'page');
else a.removeAttribute('aria-current');
}
function hideStoryTab() {
if (storyTabShown) storyTab.remove();
storyTabShown = false;
}
function notFound(main) {
document.title = 'Not found · Bindery';
fill(main, h('section', { class: 'card' }, h('h1', {}, 'Page not found'), h('p', {}, h('a', { href: '#/' }, 'Go to the story list'))));
}
function adminOnly(main) {
document.title = 'Settings · Bindery';
fill(
main,
h('section', { class: 'card' }, h('h1', {}, 'Settings'), h('p', {}, 'Only an admin can open the settings. BINDERY_ADMIN_USERS lists the admins.')),
);
}
function route() {
const main = document.getElementById('main');
if (!main) return;
cleanup?.();
cleanup = null;
const path = currentPath();
// The page of another story reports its own title.
if (storyTab?.getAttribute('href') !== `#/story/${currentStoryId()}`) hideStoryTab();
const found = ROUTES.find(([pattern]) => pattern.test(path));
if (found) {
const [pattern, page, names, admin] = found;
const match = path.match(pattern);
if (admin && !isAdmin()) adminOnly(main);
else cleanup = page(main, Object.fromEntries(names.map((name, i) => [name, match[i + 1]])), config) || null;
} else {
notFound(main);
}
for (const a of document.querySelectorAll('header nav a')) markCurrent(a, path);
window.scrollTo(0, 0);
}
async function logout() {
await api('auth/logout', 'POST');
if (auth.logout_url) {
window.location.href = auth.logout_url;
return;
}
await start();
}
function shell() {
const user =
auth.mode !== 'none' &&
auth.user && [
h('span', {}, auth.user),
(auth.mode === 'cwa' || auth.mode === 'local' || auth.logout_url) &&
h('button', { type: 'button', onclick: () => logout().catch(showError) }, 'Log out'),
];
nav = h('nav', { 'aria-label': 'Main navigation' }, h('a', { href: '#/' }, 'Stories'), isAdmin() && h('a', { href: '#/settings' }, 'Settings'));
storyTab = h('a', { class: 'story-tab' });
storyTabShown = false;
fill(
app,
h(
'header',
{},
h('a', { href: '#/', class: 'brand' }, h('img', { src: 'static/favicon.svg', alt: '', width: 22, height: 22 }), 'Bindery'),
h('div', { class: 'account' }, user),
nav,
),
h('main', { id: 'main' }),
);
}
function loginPage() {
document.title = 'Log in · Bindery';
if (auth.mode === 'proxy') {
fill(
app,
h(
'main',
{ class: 'login' },
h('div', { class: 'card' }, h('h1', {}, 'Bindery'), h('p', {}, 'Your reverse proxy did not send a user name. Log in through the proxy.')),
),
);
return;
}
const error = h('p', { class: 'error', role: 'alert' });
const username = h('input', { name: 'username', autocomplete: 'username', required: true });
const password = h('input', { name: 'password', type: 'password', autocomplete: 'current-password', required: true });
const remember = h('input', { type: 'checkbox', checked: true });
const submit = h('button', { type: 'submit', class: 'primary' }, 'Log in');
const form = h(
'form',
{
class: 'card',
onsubmit: async (e) => {
e.preventDefault();
submit.disabled = true;
error.textContent = '';
try {
await api('auth/login', 'POST', { username: username.value, password: password.value, remember: remember.checked });
await start();
} catch (err) {
error.textContent = err.message;
} finally {
submit.disabled = false;
}
},
},
h('h1', { class: 'brand' }, h('img', { src: 'static/favicon.svg', alt: '', width: 28, height: 28 }), 'Bindery'),
h('p', { class: 'hint' }, auth.mode === 'local' ? 'Log in with your Bindery account.' : 'Log in with your CWA account.'),
h('label', {}, 'User name', username),
h('label', {}, 'Password', password),
h('label', {}, remember, ' Keep me logged in'),
error,
submit,
);
fill(app, h('main', { class: 'login' }, form));
username.focus();
}
async function start() {
cleanup?.();
cleanup = null;
auth = await api('auth/check');
if (!auth.authenticated) {
loggedIn = false;
stopPolling();
loginPage();
return;
}
config = await api('config');
loggedIn = true;
shell();
route();
startPolling();
}
setStoryTitleHandler((storyId, title) => {
if (!storyTab || !title || storyId !== currentStoryId()) return;
fill(storyTab, title);
storyTab.setAttribute('title', title);
storyTab.setAttribute('href', `#/story/${storyId}`);
markCurrent(storyTab, currentPath());
if (!storyTabShown) nav.append(storyTab);
storyTabShown = true;
});
// A 401 means the session ended. Show the login page once, not on each failed poll.
// A reverse proxy often needs a full page load to step in again, and its own login page has no
// form to retry from, so reload the page instead of re-checking auth inside the app.
setUnauthorizedHandler(() => {
if (!loggedIn) return;
if (auth?.mode === 'proxy') window.location.reload();
else start().catch(showError);
});
window.addEventListener('hashchange', () => {
if (loggedIn) route();
});
start().catch((e) => {
fill(app, h('main', { class: 'login' }, h('div', { class: 'card' }, h('h1', {}, 'Bindery'), h('p', { class: 'error' }, e.message))));
});