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.
200 lines
6.7 KiB
JavaScript
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))));
|
|
});
|