Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The Books, Now Playing and Backup widgets now show why they failed, such as a
missing API key or a rejected password, instead of a generic error.

- The last visible row in a Settings group no longer draws a separator under
itself, which showed under Password Protection whenever protection was off.

### Security

- A Connections tile that cannot reach a service now says so in plain words
Expand Down
8 changes: 4 additions & 4 deletions ui/admin/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -89,17 +89,17 @@
<input type="hidden" id="log-level" value="info">
</div>
<div class="row"><span class="rl" data-i18n="general.dockerHealth">Docker Container Health Checks</span><label class="tog" aria-label="Docker Container Health Checks"><input type="checkbox" id="srv-docker-en"><div class="tr"></div></label></div>
<div class="row" id="srv-hide-healthy-row" style="display:none"><span class="rl" data-i18n="general.hideHealthy">Hide Healthy Badge</span><label class="tog" aria-label="Hide Healthy Badge"><input type="checkbox" id="srv-hide-healthy"><div class="tr"></div></label></div>
<div class="row ie-row" id="ie-socket" style="display:none"><span class="rl" data-i18n="general.socketUrl">Socket URL</span><span class="rv is-ph" id="ie-socket-v">http://socket-proxy:2375</span><button class="pe" type="button" aria-label="Edit socket URL"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5"/><path d="M18.4 2.6a1.85 1.85 0 0 1 2.6 2.6l-9.1 9.1-3.4 1 1-3.4z"/></svg></button></div>
<div class="row d-none" id="srv-hide-healthy-row"><span class="rl" data-i18n="general.hideHealthy">Hide Healthy Badge</span><label class="tog" aria-label="Hide Healthy Badge"><input type="checkbox" id="srv-hide-healthy"><div class="tr"></div></label></div>
<div class="row ie-row d-none" id="ie-socket"><span class="rl" data-i18n="general.socketUrl">Socket URL</span><span class="rv is-ph" id="ie-socket-v">http://socket-proxy:2375</span><button class="pe" type="button" aria-label="Edit socket URL"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5"/><path d="M18.4 2.6a1.85 1.85 0 0 1 2.6 2.6l-9.1 9.1-3.4 1 1-3.4z"/></svg></button></div>
</div>
<p class="grp-tip" data-i18n="general.loggingTip">Errors shows warnings and errors only. Security events are always logged.</p>
<p class="grp-tip" id="socket-hint" style="display:none" data-i18n-html="general.socketTip">Use the URL of a <strong>socket proxy</strong> container for least-privilege access.</p>

<div class="grp-hdr" data-i18n="general.security">Security</div>
<div class="grp">
<div class="row"><span class="rl" data-i18n="general.passwordProtection">Password Protection</span><label class="tog" aria-label="Password Protection"><input type="checkbox" id="sec-en"><div class="tr"></div></label></div>
<div class="row ie-row" id="ie-pw" style="display:none"><span class="rl" data-i18n="general.password">Password</span><span class="rv is-ph" id="ie-pw-v">Not set</span><button class="pe" type="button" aria-label="Edit password"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5"/><path d="M18.4 2.6a1.85 1.85 0 0 1 2.6 2.6l-9.1 9.1-3.4 1 1-3.4z"/></svg></button></div>
<div class="row" id="sec-revoke-row" style="display:none"><span class="rl" data-i18n="general.signOutEverywhere">Sign out all devices</span><div class="row-btns"><button class="btn bg sm" id="sec-revoke" type="button" data-i18n="general.signOutEverywhereBtn">Sign out all</button></div></div>
<div class="row ie-row d-none" id="ie-pw"><span class="rl" data-i18n="general.password">Password</span><span class="rv is-ph" id="ie-pw-v">Not set</span><button class="pe" type="button" aria-label="Edit password"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5"/><path d="M18.4 2.6a1.85 1.85 0 0 1 2.6 2.6l-9.1 9.1-3.4 1 1-3.4z"/></svg></button></div>
<div class="row d-none" id="sec-revoke-row"><span class="rl" data-i18n="general.signOutEverywhere">Sign out all devices</span><div class="row-btns"><button class="btn bg sm" id="sec-revoke" type="button" data-i18n="general.signOutEverywhereBtn">Sign out all</button></div></div>
</div>
<p class="grp-tip" id="revoke-tip" style="display:none" data-i18n="general.signOutEverywhereTip">Signs out every browser and device, including this one. Use it if you think a session may have been left open or compromised. Your password does not change.</p>
<p class="grp-tip" id="pw-hint-static" style="display:none" data-i18n-html="general.passwordTip">Leave blank to keep existing password. Min 8 characters. For stronger protection, consider placing the dashboard behind a <strong>dedicated authentication proxy</strong>. This built-in password is a lightweight gate, not a full authentication layer.</p>
Expand Down
7 changes: 5 additions & 2 deletions ui/css/admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,11 @@ html{background:var(--bg-color,var(--bg-outer))}

/* ── Rows ── */
.row{display:flex;align-items:center;gap:10px;padding:0 16px;min-height:52px;border-bottom:1px solid var(--bd-inner)}
.row:last-child{border-bottom:none}
/* The separator belongs between two visible rows. :last-child is a DOM
position, and a group's trailing rows can be hidden, which left a border
under the last row anyone could see. Hidden rows carry .d-none, never an
inline display, so this can ask the question. */
.row:not(:has(~ .row:not(.d-none))){border-bottom:none}
.row-link{text-decoration:none;color:inherit;transition:background var(--t)}
.row-link:hover{background:rgba(255,255,255,.04)}
.rl{flex:1;font-size:14px;color:var(--tx);font-weight:400}
Expand Down Expand Up @@ -140,7 +144,6 @@ html{background:var(--bg-color,var(--bg-outer))}
.chip.on{background:var(--ac);color:#fff}
.chip:hover:not(.on){color:var(--tx)}
.row.drow{display:grid;grid-template-columns:20px 48px 1fr auto auto;gap:12px;padding:10px 16px;min-height:66px;cursor:grab;border-bottom:1px solid var(--bd-inner);align-items:center}
.row.drow:last-child{border-bottom:none}
.row.drow:hover{background:rgba(255,255,255,.025)}
.row.drow.dragging{opacity:.4;cursor:grabbing}
.row.drow.drag-above{box-shadow:0 -2px 0 0 var(--ac)}
Expand Down
8 changes: 4 additions & 4 deletions ui/js/admin-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,9 @@ export function loadSettings(c) {
dockerEnEl.checked = !!s.server?.socketProxyUrl;
const applyDocker = v => {
if (dockerSubEl) dockerSubEl.classList.toggle('open', v);
if (hideHealthyRowEl) hideHealthyRowEl.style.display = v ? '' : 'none';
if (hideHealthyRowEl) hideHealthyRowEl.classList.toggle('d-none', !v);
const socketRow = el('ie-socket');
if (socketRow) socketRow.style.display = v ? '' : 'none';
if (socketRow) socketRow.classList.toggle('d-none', !v);
const socketHint = el('socket-hint');
if (socketHint) socketHint.style.display = v ? '' : 'none';
};
Expand Down Expand Up @@ -188,7 +188,7 @@ export function loadSettings(c) {
const on = !!secEnEl?.checked;
if (secLogout) secLogout.classList.toggle('d-none', !on);
const canRevoke = on && _passwordSet;
if (secRevokeRow) secRevokeRow.style.display = canRevoke ? '' : 'none';
if (secRevokeRow) secRevokeRow.classList.toggle('d-none', !canRevoke);
if (revokeTip) revokeTip.style.display = canRevoke ? '' : 'none';
};
secLogout?.addEventListener('click', async () => {
Expand Down Expand Up @@ -226,7 +226,7 @@ export function loadSettings(c) {
secEnEl.checked = !!d.enabled;
const pwRow = el('ie-pw');
const pwHint = el('pw-hint-static');
if (pwRow) pwRow.style.display = d.enabled ? '' : 'none';
if (pwRow) pwRow.classList.toggle('d-none', !d.enabled);
if (pwHint) pwHint.style.display = d.enabled ? '' : 'none';
}
const pwValEl = el('ie-pw-v');
Expand Down
6 changes: 3 additions & 3 deletions ui/js/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -1096,7 +1096,7 @@ function initSecToggle() {
const pwHint = el('pw-hint-static');
if (!en) return;
function apply(on) {
if (pwRow) pwRow.style.display = on ? '' : 'none';
if (pwRow) pwRow.classList.toggle('d-none', !on);
if (pwHint) pwHint.style.display = on ? '' : 'none';
}
apply(en.checked);
Expand All @@ -1109,8 +1109,8 @@ function initDockerToggle() {
const socketRow = el('ie-socket');
if (!en) return;
function apply(on) {
if (hideRow) hideRow.style.display = on ? '' : 'none';
if (socketRow) socketRow.style.display = on ? '' : 'none';
if (hideRow) hideRow.classList.toggle('d-none', !on);
if (socketRow) socketRow.classList.toggle('d-none', !on);
}
apply(en.checked);
en.addEventListener('change', () => apply(en.checked));
Expand Down
70 changes: 70 additions & 0 deletions ui/test/admin-row-separator.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

/* A settings row's separator belongs between two rows the user can see.

The rule was `.row:last-child { border-bottom: none }`, which asks a question
about position in the DOM rather than about what is on screen. Security's
group ends with two rows that stay hidden until password protection is on, so
the exemption landed on a hidden row and the visible last row, Password
Protection, drew a border under itself with nothing beneath it.

The fix needs both halves to hold: the stylesheet has to ask whether a later
row is visible, and a hidden row has to be hidden in a way CSS can see. An
inline style="display:none" is invisible to a selector, so rows use .d-none. */

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const read = p => fs.readFileSync(path.join(root, p), 'utf8');
const css = read('css/admin.css');
const html = read('admin/index.html');

test('the separator is removed from the last visible row, not the last child', () => {
assert.match(
css,
/\.row:not\(:has\(~ \.row:not\(\.d-none\)\)\)\{border-bottom:none\}/,
'the visibility-aware rule is gone',
);
assert.doesNotMatch(
css,
/\.row(\.\w+)?:last-child\{border-bottom:none\}/,
'a :last-child exemption is back; it cannot see a hidden trailing row',
);
});

test('a row hides with the class, never with an inline style', () => {
const offenders = [...html.matchAll(/<div[^>]*class="row[^"]*"[^>]*>/g)]
.map(m => m[0])
.filter(tag => /style="[^"]*display\s*:\s*none/.test(tag))
.map(tag => {
const id = /id="([^"]+)"/.exec(tag);
return id ? id[1] : '(no id)';
});
assert.deepEqual(
offenders,
[],
`These rows hide in a way the stylesheet cannot see. Use class="… d-none":\n ${offenders.join('\n ')}`,
);
});

test('nothing toggles a row back to an inline display', () => {
const offenders = [];
for (const file of ['js/admin.js', 'js/admin-settings.js']) {
const src = read(file).replace(/\/\*[\s\S]*?\*\//g, '');
for (const m of src.matchAll(/(\w*[Rr]ow\w*)\.style\.display\s*=/g)) {
offenders.push(`${file}: ${m[0]}`);
}
}
assert.deepEqual(
offenders,
[],
`Use classList.toggle('d-none', …) so the separator rule still applies:\n ${offenders.join('\n ')}`,
);
});

/* The class has to actually hide, and to win against the row's own display. */
test('d-none is defined and beats the row layout', () => {
assert.match(css, /\.d-none\{display:none!important\}/);
});
Loading