diff --git a/bindery/static/js/settings.js b/bindery/static/js/settings.js index 0e55d1b..05cd2f1 100644 --- a/bindery/static/js/settings.js +++ b/bindery/static/js/settings.js @@ -9,6 +9,15 @@ const DEFAULTS_INI = 'https://github.com/JimmXinu/FanFicFare/blob/main/fanficfar const TEMPLATE_DOCS = 'https://manual.calibre-ebook.com/template_lang.html'; const DATE_CODES = 'https://docs.python.org/3/library/datetime.html#format-codes'; const code = (text) => h('code', {}, text); +// How a reader logs in to the catalog, for each login mode. +const OPDS_LOGIN = { + none: 'Bindery asks for no login, so the catalog needs none.', + local: 'The reader sends your Bindery user name and password with HTTP Basic authentication.', + cwa: 'The reader sends your CWA user name and password with HTTP Basic authentication.', + proxy: + 'The proxy skips its login for the catalog. Each user sets an OPDS password below. The reader sends that ' + + 'password and the user name from the proxy with HTTP Basic authentication.', +}; // A card of settings that folds shut. It starts open. const card = (title, ...children) => h('details', { class: 'card', open: true }, h('summary', {}, h('h2', {}, title)), ...children); @@ -42,6 +51,47 @@ export function settingsPage(main, _params, config) { pauseUploads.setAttribute('aria-describedby', pauseUploads.disabled ? 'ingest-note' : ''); ingestNote.hidden = !pauseUploads.disabled; }; + const opdsEnabled = checkbox(false, (checked) => showPassword(passwordSet, checked), { + id: 'opds-enabled', + 'aria-describedby': 'opds-note', + }); + let passwordSet = false; + const opdsUser = h('input', { + id: 'opds-user', + type: 'text', + readonly: true, + spellcheck: 'false', + autocomplete: 'off', + 'aria-describedby': 'opds-password-status', + }); + const opdsPassword = h('input', { + id: 'opds-password', + type: 'password', + minlength: 8, + maxlength: 256, + autocomplete: 'new-password', + }); + const opdsPasswordStatus = h('p', { class: 'hint', id: 'opds-password-status' }); + const setPassword = button('Set password', async () => { + await api('settings/opds-password', 'PUT', { password: opdsPassword.value }); + opdsPassword.value = ''; + showPassword(true, opdsEnabled.checked); + toast('Saved'); + }); + // The password applies only when the catalog is on, so the field is grey while the box is not checked. + const showPassword = (isSet, enabled) => { + passwordSet = isSet; + opdsPassword.disabled = !enabled; + setPassword.disabled = !enabled; + setPassword.textContent = isSet ? 'Reset password' : 'Set password'; + fill( + opdsPasswordStatus, + isSet + ? 'You have an OPDS password. Bindery keeps only a hash of it. ' + + 'Enter the user name and the password in your reader.' + : 'You have no OPDS password yet. Set one, then enter the user name and the password in your reader.', + ); + }; const template = h('input', { id: 'download-template', class: 'template', @@ -96,6 +146,9 @@ export function settingsPage(main, _params, config) { recent.value = settings.recent_chapters; showUploadSettings(settings); ini.value = settings.personal_ini; + opdsEnabled.checked = settings.opds_enabled ?? false; + opdsUser.value = settings.opds_user ?? ''; + showPassword(settings.opds_password_set ?? false, opdsEnabled.checked); template.value = settings.download_template; dateFormat.value = settings.download_date_format; storyJobsHours.value = settings.story_jobs_hours; @@ -162,12 +215,33 @@ export function settingsPage(main, _params, config) { ), card( 'Book Downloads', + h('label', {}, opdsEnabled, ' Enable OPDS'), + h( + 'p', + { class: 'hint', id: 'opds-note' }, + 'OPDS lets a reader browse and download books. It is off by default. After you enable it and save, use ', + h('a', { href: 'opds' }, 'the OPDS catalog'), + '. ', + OPDS_LOGIN[config.auth_mode] ?? OPDS_LOGIN.local, + ' Use HTTPS outside a trusted local network.', + ), + config.auth_mode === 'proxy' && [ + h( + 'div', + { class: 'credentials' }, + h('label', { for: 'opds-user' }, 'OPDS user name'), + opdsUser, + h('label', { for: 'opds-password' }, 'OPDS password'), + h('div', { class: 'controls' }, opdsPassword, setPassword), + ), + opdsPasswordStatus, + ], h('div', { class: 'controls' }, h('label', { for: 'download-template' }, 'File name template'), template), h( 'p', { class: 'hint' }, - 'This template gives the file name of a book that you download from a story page. To go back to the ' + - 'default, ', + 'This template gives the file name of a book that you download from a story page or OPDS. ' + + 'To go back to the default, ', code('{title_sort} - {authors}'), ', clear the field and save.', ), @@ -234,7 +308,7 @@ export function settingsPage(main, _params, config) { h( 'p', { class: 'hint' }, - 'The form of the dates in {pubdate}, {timestamp} and {last_modified}. To go back to the default, ', + 'The form of template dates in {pubdate}, {timestamp} and {last_modified}. To go back to the default, ', code('%b, %Y'), ', clear the field and save.', ), @@ -258,8 +332,14 @@ export function settingsPage(main, _params, config) { ), ), saveButton( - () => ({ download_template: template.value, download_date_format: dateFormat.value }), + () => ({ + opds_enabled: opdsEnabled.checked, + download_template: template.value, + download_date_format: dateFormat.value, + }), (settings) => { + opdsEnabled.checked = settings.opds_enabled ?? false; + showPassword(passwordSet, opdsEnabled.checked); template.value = settings.download_template; dateFormat.value = settings.download_date_format; toast('Saved'); diff --git a/bindery/static/style.css b/bindery/static/style.css index 5b26594..627e51c 100644 --- a/bindery/static/style.css +++ b/bindery/static/style.css @@ -575,6 +575,11 @@ button.danger:hover:not(:disabled) { background: var(--err-bg); } +input:not([type='checkbox'], [type='radio']):disabled { + opacity: 0.55; + cursor: not-allowed; +} + button:disabled { opacity: 0.55; cursor: default; @@ -718,6 +723,30 @@ textarea { line-height: 1.45; } +/* A user name over a password, with the labels in one column. */ +.credentials { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + align-items: center; + gap: 8px 12px; + max-width: 48em; + margin-top: 8px; + + input { + width: 18em; + } + + input[readonly] { + background: var(--card-bg); + font-family: var(--mono); + cursor: default; + } + + .controls { + margin-top: 0; + } +} + .template { flex: 1 1 24em; max-width: 48em; diff --git a/tests/js/flows.test.mjs b/tests/js/flows.test.mjs index 9202f70..9b273cd 100644 --- a/tests/js/flows.test.mjs +++ b/tests/js/flows.test.mjs @@ -144,7 +144,7 @@ test('the download template loads, lists its fields, and saves the stored form', api.on('PUT', 'settings', { download_template: '{title} - {authors}', download_date_format: '%b, %Y' }); await click(card, 'Save'); assert.deepEqual(api.sent('PUT', 'settings')[0].body, { - download_template: ' {title} - {authors} ', download_date_format: '', + opds_enabled: false, download_template: ' {title} - {authors} ', download_date_format: '', }); assert.equal(field(main, 'download-template').value, '{title} - {authors}'); assert.equal(field(main, 'download-date-format').value, '%b, %Y'); diff --git a/tests/js/opds-settings.test.mjs b/tests/js/opds-settings.test.mjs new file mode 100644 index 0000000..f2a9d43 --- /dev/null +++ b/tests/js/opds-settings.test.mjs @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { Element, change, click, field, find, install, nodes, section, server, settle } from './flow-helpers.mjs'; + +install(); +const { settingsPage } = await import('../../bindery/static/js/settings.js'); + +async function open(t, settings, authMode = 'proxy') { + install(); + t.mock.timers.enable({ apis: ['setTimeout'] }); + const backend = server(t); + const stored = { download_template: '{title}', download_date_format: '%Y', personal_ini: '', ...settings }; + backend.on('GET', 'settings', stored); + backend.on('GET', 'rules', { rules: [] }); + backend.on('GET', 'stories', []); + backend.on('PUT', 'settings', ({ body }) => ({ ...stored, ...body })); + backend.on('PUT', 'settings/opds-password', { opds_user: 'alice', opds_password_set: true }); + const main = new Element('main'); + settingsPage(main, {}, { cwa_enabled: false, download_fields: {}, auth_mode: authMode }); + await settle(); + return { backend, main, card: section(main, 'Book Downloads') }; +} + +for (const enabled of [false, true]) { + test(`OPDS loads ${enabled}, appears first in Book Downloads, and saves both states`, async (t) => { + const { backend, main, card } = await open(t, { opds_enabled: enabled }); + const toggle = field(card, 'opds-enabled'); + assert.equal(toggle.checked, enabled); + assert.equal(card.children[1].tagName, 'label'); + assert.equal(card.children[1].children[0], toggle); + assert.equal(card.children[1].textContent, ' Enable OPDS'); + assert.equal(toggle.getAttribute('aria-describedby'), 'opds-note'); + assert.match(field(card, 'opds-note').textContent, /off by default/); + assert.equal(find(field(card, 'opds-note'), 'a', 'the OPDS catalog').getAttribute('href'), 'opds'); + assert.equal(nodes(main).filter((node) => node.getAttribute('id') === 'opds-enabled').length, 1); + for (const value of [!enabled, enabled]) { + change(toggle, value); + await click(card, 'Save'); + assert.deepEqual(backend.sent('PUT', 'settings').at(-1).body, { + opds_enabled: value, download_template: '{title}', download_date_format: '%Y', + }); + assert.equal(toggle.checked, value); + } + backend.on('PUT', 'settings', { opds_enabled: false, download_template: '{title}', download_date_format: '%Y' }); + change(toggle, true); + await click(card, 'Save'); + assert.equal(toggle.checked, false, 'The saved response sets the checkbox state'); + }); +} + +const LOGIN = { + none: /Bindery asks for no login, so the catalog needs none/, + local: /sends your Bindery user name and password/, + cwa: /sends your CWA user name and password/, + proxy: /The proxy skips its login for the catalog.*Each user sets an OPDS password.*user name from the proxy/, +}; +for (const [mode, text] of Object.entries(LOGIN)) { + test(`the note names the login of the ${mode} mode, and only the proxy mode shows the password`, async (t) => { + const { card } = await open(t, { opds_enabled: true, opds_password_set: false, opds_user: 'alice' }, mode); + assert.match(field(card, 'opds-note').textContent, text); + assert.doesNotMatch(field(card, 'opds-note').textContent, /BINDERY_API_KEY|Bearer|X-Api-Key/); + for (const id of ['opds-user', 'opds-password', 'opds-password-status']) { + assert.equal(field(card, id) !== undefined, mode === 'proxy', id); + } + assert.equal(find(card, 'button', 'Set password') !== undefined, mode === 'proxy'); + }); +} + +test('the password field is grey until the box is checked, and follows the box at once', async (t) => { + const { card } = await open(t, { opds_enabled: false, opds_password_set: true, opds_user: 'alice' }); + const password = field(card, 'opds-password'); + const button = find(card, 'button', 'Reset password'); + assert.equal(password.disabled, true); + assert.equal(button.disabled, true); + change(field(card, 'opds-enabled'), true); + assert.equal(password.disabled, false); + assert.equal(button.disabled, false); + change(field(card, 'opds-enabled'), false); + assert.equal(password.disabled, true); + assert.equal(button.disabled, true); +}); + +for (const isSet of [false, true]) { + test(`the OPDS password ${isSet ? 'is set' : 'is not set'}, and the button sets or resets it`, async (t) => { + const { backend, card } = await open(t, { opds_enabled: true, opds_password_set: isSet, opds_user: 'alice' }); + const password = field(card, 'opds-password'); + const status = field(card, 'opds-password-status'); + const user = field(card, 'opds-user'); + assert.equal(user.value, 'alice'); + assert.equal(user.getAttribute('readonly'), ''); + assert.equal(user.disabled, false, 'The user name stays selectable'); + assert.equal(user.getAttribute('aria-describedby'), 'opds-password-status'); + assert.equal(find(card, 'label', 'OPDS user name').getAttribute('for'), 'opds-user'); + assert.equal(find(card, 'label', 'OPDS password').getAttribute('for'), 'opds-password'); + assert.equal(password.disabled, false); + assert.equal(password.getAttribute('type'), 'password'); + assert.equal(password.getAttribute('autocomplete'), 'new-password'); + assert.equal(password.getAttribute('minlength'), '8'); + assert.equal(password.value, ''); + assert.match(status.textContent, /enter the user name and the password in your reader/i); + assert.match(status.textContent, isSet ? /You have an OPDS password/ : /You have no OPDS password yet/); + assert.ok(find(card, 'button', isSet ? 'Reset password' : 'Set password')); + password.value = 'long enough'; + await click(card, isSet ? 'Reset password' : 'Set password'); + assert.deepEqual(backend.sent('PUT', 'settings/opds-password')[0].body, { password: 'long enough' }); + assert.equal(password.value, '', 'The field is empty after the save'); + assert.match(status.textContent, /You have an OPDS password/); + assert.equal(password.disabled, false); + assert.ok(find(card, 'button', 'Reset password')); + // The Save button of the card never sends the password. + password.value = 'not sent'; + await click(card, 'Save'); + assert.equal(backend.sent('PUT', 'settings/opds-password').length, 1); + assert.equal('password' in backend.sent('PUT', 'settings')[0].body, false); + assert.equal(password.disabled, false, 'A save keeps the field in step with the box'); + }); +} + +test('a rejected OPDS password keeps the field and the status', async (t) => { + const { backend, card } = await open(t, { opds_enabled: true, opds_password_set: false }); + backend.on('PUT', 'settings/opds-password', { error: 'The OPDS password must be text of 8 to 256 characters' }, 400); + field(card, 'opds-password').value = 'short'; + await click(card, 'Set password'); + assert.equal(field(card, 'opds-password').value, 'short'); + assert.match(field(card, 'opds-password-status').textContent, /You have no OPDS password yet/); + assert.ok(find(card, 'button', 'Set password')); +});