feat(setup): selections above, drives below, and both roots choosable

Reworks the Storage step: two labelled choices with tooltips at the top —
LibrePortal and New apps — and the drive list underneath as reference.

The drive cards lose their checkboxes. Choosing a drive in a dropdown IS
the request to register it, so a separate tick was a second way to say the
same thing, and the way you end up with a drive ticked that nobody
selected. Cards are now informational plus Details.

Both dropdowns only render when there is a second drive; with one disk
both answers are forced and a pair of selects showing one option each is
furniture.

Moving LibrePortal's own tree cannot be a WebUI action. It re-bakes the
six root-owned helpers, the systemd unit and the WebUI's own bind-mounts
— real root, not the scoped sudo the manager holds. A helper that re-baked
the other helpers from a manager-supplied path would hand the manager
exactly the trust boundary those helpers exist to defend. So picking a
different disk for LibrePortal surfaces the root command to run rather
than pretending the wizard can do it; the payload carries the choice so
the finish screen can repeat it. libreportal-relocate follows.

Also drops "itself" from the installer's wording.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-26 06:39:25 +01:00
parent 3034eaf4c7
commit 6fd6587714
4 changed files with 125 additions and 73 deletions

View File

@ -1391,3 +1391,32 @@ body.setup-wizard-open .eo-modal { z-index: 10000; }
other dropdowns rather than showing stock white chrome. */ other dropdowns rather than showing stock white chrome. */
.setup-storage-default select, .setup-storage-default select,
.setup-storage-default .custom-select { flex: 1; min-width: 180px; } .setup-storage-default .custom-select { flex: 1; min-width: 180px; }
/* Two choices above the drive list. The list below is reference, not input. */
.setup-storage-choice {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.setup-storage-choice-label {
flex: 0 0 120px;
font-size: 0.92em;
opacity: 0.92;
}
.setup-storage-choice select,
.setup-storage-choice .custom-select { flex: 1; min-width: 180px; }
.setup-storage-choice-msg {
margin: 2px 0 4px;
padding: 9px 11px;
border-radius: 8px;
border: 1px solid rgba(255, 190, 60, 0.32);
background: rgba(255, 190, 60, 0.09);
font-size: 0.86em;
line-height: 1.5;
}
.setup-storage-choice-msg code {
display: inline-block;
margin-top: 4px;
word-break: break-all;
}

View File

@ -34,6 +34,8 @@ class SetupWizard {
this.fstabWanted = []; this.fstabWanted = [];
// Which drive new apps default to: 'primary' or a ticked drive's path. // Which drive new apps default to: 'primary' or a ticked drive's path.
this.storageDefault = 'primary'; this.storageDefault = 'primary';
// Where LibrePortal's own tree should live (root-only to change post-install).
this.storageSystemChoice = 'primary';
this.installLevel = 'beginner'; this.installLevel = 'beginner';
this.totalSteps = this._effectiveTotalSteps(); this.totalSteps = this._effectiveTotalSteps();
this.domainCount = 0; // tracked dynamically as the user adds rows this.domainCount = 0; // tracked dynamically as the user adds rows
@ -238,8 +240,9 @@ class SetupWizard {
<div class="setup-section-title">Storage <div class="setup-section-title">Storage
<span class="setup-tooltip" tabindex="0" data-tip="Apps normally live on the system disk. If you have another drive, you can register it here and choose per app where its data goes.">?</span> <span class="setup-tooltip" tabindex="0" data-tip="Apps normally live on the system disk. If you have another drive, you can register it here and choose per app where its data goes.">?</span>
</div> </div>
<div id="sw-storage-choices"></div>
<div class="setup-storage-divider" id="sw-storage-drives-head" style="display:none;"><span>Drives</span></div>
<div id="sw-storage-list"></div> <div id="sw-storage-list"></div>
<div id="sw-storage-default" class="setup-storage-default" style="display:none;"></div>
<p class="setup-section-hint" id="sw-storage-note" style="margin-top:10px;"></p> <p class="setup-section-hint" id="sw-storage-note" style="margin-top:10px;"></p>
</div> </div>
</section> </section>
@ -476,39 +479,27 @@ class SetupWizard {
title="${used}% used, ${100 - used}% free"><span style="width:${width}%"></span></span>`; title="${used}% used, ${100 - used}% free"><span style="width:${width}%"></span></span>`;
} }
// One card, one line. // One card, one line — informational.
//
// No checkbox: the dropdowns above ARE the selection, and a drive picked
// there is registered automatically. Two ways to say the same thing (tick it
// AND choose it) is how you end up with a ticked drive nobody selected.
// //
// The badge carries severity and Details carries the explanation, so the card // The badge carries severity and Details carries the explanation, so the card
// itself stays a single row — listing every warning here pushed each drive to // stays a single row.
// three lines and made the step tall for no gain.
//
// `locked` renders it ticked and non-interactive: the system drive is what
// apps fall back to, so it cannot be deselected. Details still works, which
// is the point of showing it at all on a single-disk box.
_storageCard(c, key, opts) { _storageCard(c, key, opts) {
const o = opts || {}; const o = opts || {};
const refused = c.verdict === 'refuse'; const refused = c.verdict === 'refuse';
const badge = o.locked const badge = o.system
? '<span class="setup-storage-badge setup-storage-badge-ok">default</span>' ? '<span class="setup-storage-badge setup-storage-badge-ok">system</span>'
: (refused ? '<span class="setup-storage-badge setup-storage-badge-bad">can\u2019t be used</span>' : (refused ? '<span class="setup-storage-badge setup-storage-badge-bad">can\u2019t be used</span>'
: (c.verdict === 'warn' ? '<span class="setup-storage-badge setup-storage-badge-warn">needs care</span>' : '')); : (c.verdict === 'warn' ? '<span class="setup-storage-badge setup-storage-badge-warn">needs care</span>' : ''));
const title = o.locked ? 'System disk' : c.path; const title = o.system ? 'System disk' : c.path;
// The system disk carries NO checkbox — not even a disabled one. Apps fall
// back to it whatever happens, so it is not a choice, and a control that
// can never change state reads as "locked option" rather than "this is
// simply where things go". It is a <div> for the same reason: a <label>
// with nothing to label is wrong. A spacer keeps the text aligned with the
// real checkboxes below it.
const control = o.locked
? '<span class="setup-storage-spacer" aria-hidden="true"></span>'
: `<input type="checkbox" data-storage-path="${this.escapeHtml(c.path)}" ${refused ? 'disabled' : ''}>`;
const tag = o.locked ? 'div' : 'label';
return ` return `
<${tag} class="setup-app setup-storage-card${refused ? ' setup-storage-disabled' : ''}${o.locked ? ' setup-storage-locked' : ''}"> <div class="setup-app setup-storage-card${refused ? ' setup-storage-disabled' : ''} setup-storage-locked">
${control} <span class="setup-storage-spacer" aria-hidden="true"></span>
<span class="setup-app-body"> <span class="setup-app-body">
<span class="setup-app-name">${this.escapeHtml(title)} ${badge}</span> <span class="setup-app-name">${this.escapeHtml(title)} ${badge}</span>
<span class="setup-app-desc"> <span class="setup-app-desc">
@ -517,25 +508,31 @@ class SetupWizard {
</span> </span>
</span> </span>
<button type="button" class="setup-storage-details" data-storage-details="${this.escapeHtml(key)}">Details</button> <button type="button" class="setup-storage-details" data-storage-details="${this.escapeHtml(key)}">Details</button>
</${tag}>`; </div>`;
}
// Every place a root could go: the system disk, plus each usable candidate.
_storageChoices() {
const opts = [{ value: 'primary', label: 'System disk' }];
this.storageCandidates
.filter(c => c.verdict !== 'refuse')
.forEach(c => opts.push({ value: c.path, label: c.path }));
return opts;
} }
renderStorage() { renderStorage() {
const list = this.container.querySelector('#sw-storage-list'); const list = this.container.querySelector('#sw-storage-list');
const head = this.container.querySelector('#sw-storage-drives-head');
const note = this.container.querySelector('#sw-storage-note'); const note = this.container.querySelector('#sw-storage-note');
if (!list) return; if (!list) return;
let html = ''; let html = '';
if (this.storageSystem) { if (this.storageSystem) {
html += this._storageCard(this.storageSystem, 'system', { locked: true }); html += this._storageCard(this.storageSystem, 'system', { system: true });
}
// Separate the fixed fact from the actual choice: above the divider is
// where apps go regardless, below it is what you can opt into.
if (this.storageCandidates.length) {
html += '<div class="setup-storage-divider"><span>Additional drives</span></div>';
} }
html += this.storageCandidates.map((c, i) => this._storageCard(c, String(i), {})).join(''); html += this.storageCandidates.map((c, i) => this._storageCard(c, String(i), {})).join('');
list.innerHTML = html; list.innerHTML = html;
if (head) head.style.display = this.storageCandidates.length ? '' : 'none';
list.querySelectorAll('[data-storage-details]').forEach((btn) => { list.querySelectorAll('[data-storage-details]').forEach((btn) => {
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
@ -545,61 +542,79 @@ class SetupWizard {
}); });
}); });
// Ticking a drive changes whether "where do new apps go?" is a real this.renderStorageChoices();
// question, so re-evaluate it on every change.
list.querySelectorAll('[data-storage-path]').forEach((cb) => {
cb.addEventListener('change', () => this.renderStorageDefault());
});
this.renderStorageDefault();
if (note) { if (note) {
// Only the single-disk case says anything: with drives listed, the cards
// and their badges already carry it, and a standing explanatory line
// under them was noise.
note.innerHTML = this.storageCandidates.length note.innerHTML = this.storageCandidates.length
? '' ? ''
: 'No other drives found. You can add one later with <code>libreportal storage</code>.'; : 'Only one drive found, so everything goes here. Add another later with <code>libreportal storage</code>.';
} }
} }
// "Where do new apps go?" — asked only when it is actually a question, i.e. // The two choices, above the drive list. Rendered only when there is another
// once at least one extra drive is ticked. With nothing ticked there is one // drive — with one disk both answers are forced and a pair of dropdowns
// possible answer and a control would be furniture. // showing the same single option is furniture.
// renderStorageChoices() {
// This sets a DEFAULT, not a per-app placement: apps ship const box = this.container.querySelector('#sw-storage-choices');
// CFG_<APP>_STORAGE=default, which means "follow the global setting", so one
// choice here places every app installed afterwards. Individual apps can
// still be pinned, and moved later with `libreportal app move`.
renderStorageDefault() {
const box = this.container.querySelector('#sw-storage-default');
if (!box) return; if (!box) return;
const ticked = this.collectStorage(); const opts = this._storageChoices();
if (!ticked.length) { if (opts.length < 2) {
box.style.display = 'none';
box.innerHTML = ''; box.innerHTML = '';
this.storageDefault = 'primary'; this.storageDefault = 'primary';
// Where LibrePortal's own tree should live (root-only to change post-install).
this.storageSystemChoice = 'primary';
this.storageSystemChoice = 'primary';
return; return;
} }
// Keep a previous choice if that drive is still ticked. const row = (id, label, tip, value) => `
if (this.storageDefault !== 'primary' && !ticked.includes(this.storageDefault)) { <div class="setup-storage-choice">
this.storageDefault = 'primary'; <span class="setup-storage-choice-label">${label}
} <span class="setup-tooltip" tabindex="0" data-tip="${this.escapeHtml(tip)}">?</span>
</span>
<select id="${id}" class="form-control">
${opts.map(o => `<option value="${this.escapeHtml(o.value)}"${o.value === value ? ' selected' : ''}>${this.escapeHtml(o.label)}</option>`).join('')}
</select>
</div>`;
const opts = [{ value: 'primary', label: 'System disk' }] box.innerHTML =
.concat(ticked.map(p => ({ value: p, label: p }))); row('sw-storage-system', 'LibrePortal',
'Settings, database and logs. Around 20 MB, and it stays small. Moving this after install needs a root command — the wizard will tell you which.',
this.storageSystemChoice) +
row('sw-storage-apps', 'New apps',
'Where an app keeps its data unless you place that app somewhere else. You can move any app later with `libreportal app move`.',
this.storageDefault) +
'<div class="setup-storage-choice-msg" id="sw-storage-system-msg" style="display:none;"></div>';
box.style.display = ''; box.querySelector('#sw-storage-system').addEventListener('change', (e) => {
box.innerHTML = ` this.storageSystemChoice = e.target.value;
<span class="setup-storage-default-label">New apps store their data on</span> this.renderStorageSystemMsg();
<select id="sw-storage-default-select" class="form-control"> });
${opts.map(o => `<option value="${this.escapeHtml(o.value)}"${o.value === this.storageDefault ? ' selected' : ''}>${this.escapeHtml(o.label)}</option>`).join('')} box.querySelector('#sw-storage-apps').addEventListener('change', (e) => {
</select>`;
box.querySelector('select').addEventListener('change', (e) => {
this.storageDefault = e.target.value; this.storageDefault = e.target.value;
}); });
this.renderStorageSystemMsg();
}
// Moving LibrePortal's own tree re-bakes the root-owned helpers, the systemd
// unit and the WebUI's own bind-mounts. That is real root, not the scoped
// sudo the manager holds — a helper that re-baked the other helpers from a
// manager-supplied path would hand the manager the trust boundary those
// helpers exist to defend. So the wizard hands over the command instead of
// pretending it can do it.
renderStorageSystemMsg() {
const msg = this.container.querySelector('#sw-storage-system-msg');
if (!msg) return;
if (!this.storageSystemChoice || this.storageSystemChoice === 'primary') {
msg.style.display = 'none';
msg.innerHTML = '';
return;
}
msg.style.display = '';
msg.innerHTML = `Moving LibrePortal itself needs root, so it happens outside the WebUI.
Finish setup, then run:<br>
<code>sudo libreportal-relocate --system-dir=${this.escapeHtml(this.storageSystemChoice)}/libreportal-system</code>`;
} }
// Details modal — the technical spec, every check with its full explanation, // Details modal — the technical spec, every check with its full explanation,
@ -706,9 +721,14 @@ class SetupWizard {
)); ));
} }
// Drives to register: whatever either dropdown points at. Selecting a drive
// IS the request to register it — there is no separate tick to forget.
collectStorage() { collectStorage() {
return Array.from(this.container.querySelectorAll('input[data-storage-path]:checked')) const out = [];
.map(cb => cb.dataset.storagePath); [this.storageSystemChoice, this.storageDefault].forEach((v) => {
if (v && v !== 'primary' && !out.includes(v)) out.push(v);
});
return out;
} }
showStep(n) { showStep(n) {
@ -1116,7 +1136,10 @@ class SetupWizard {
// Only for drives that are actually being registered. // Only for drives that are actually being registered.
storage_fstab: this.fstabWanted.filter(p => this.collectStorage().includes(p)), storage_fstab: this.fstabWanted.filter(p => this.collectStorage().includes(p)),
// 'primary', or the path of a drive also present in `storage`. // 'primary', or the path of a drive also present in `storage`.
storage_default: (this.storageDefault !== 'primary' && this.collectStorage().includes(this.storageDefault)) ? this.storageDefault : 'primary' storage_default: (this.storageDefault && this.storageDefault !== 'primary') ? this.storageDefault : 'primary',
// Recorded so the finished-setup screen can repeat the relocate command.
// Nothing acts on it: moving LibrePortal's own tree needs real root.
storage_system: (this.storageSystemChoice && this.storageSystemChoice !== 'primary') ? this.storageSystemChoice : 'primary'
}; };
// Apply the experience choice to the WebUI immediately so the next // Apply the experience choice to the WebUI immediately so the next

View File

@ -35,7 +35,7 @@ Where should LibrePortal keep things?
1) This disk (default) 911.9G 808.4G free 1) This disk (default) 911.9G 808.4G free
2) /mnt/bigdisk 3.6T 3.6T free 2) /mnt/bigdisk 3.6T 3.6T free
LibrePortal itself — settings, database, logs. Around 20 MB, and it stays small. LibrePortal — settings, database, logs. Around 20 MB, and it stays small.
Choose [1]: Choose [1]:
App data — everything your apps store. This is the one that grows. App data — everything your apps store. This is the one that grows.

View File

@ -286,7 +286,7 @@ initPickRoots()
local chosen local chosen
if [[ -z "${LP_SYSTEM_DIR_EXPLICIT:-}" ]]; then if [[ -z "${LP_SYSTEM_DIR_EXPLICIT:-}" ]]; then
chosen=$(_initAskDisk "LibrePortal itself — settings, database, logs. Around 20 MB, and it stays small.") chosen=$(_initAskDisk "LibrePortal — settings, database, logs. Around 20 MB, and it stays small.")
if [[ -n "$chosen" ]]; then if [[ -n "$chosen" ]]; then
LP_SYSTEM_DIR="${chosen%/}/libreportal-system" LP_SYSTEM_DIR="${chosen%/}/libreportal-system"
isSuccessful "LibrePortal will live in $LP_SYSTEM_DIR" isSuccessful "LibrePortal will live in $LP_SYSTEM_DIR"