user form, backup: two gaps the mockup comparison found

Owner, 2026-10-18: "Что ещё из макетов мы также прошляпили?" - every mockup
held against the panel; two small gaps beyond the queues:

- user.html: with All ticked, a list's single domains are disabled
  (mockup user.html:34-36). A panel.js show-when rule with disable: the
  boxes data-single-out / data-single-in follow data-all-out / data-all-in,
  at load and on change; a disabled box is not submitted, which the server
  ignores with All anyway. node test testdata/alldomains_test.js (fails on
  the previous panel.js).
- backup.html: "domain's settings" is a link, to Outbound › Domains (mockup
  backup.html:35 links a domain's settings; a server page has no one domain).

Tests: go test ./internal/web/... pass, TestPanelJS with node 24.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-18 23:30:44 +03:00
co-authored by Claude Opus 5.5
parent 6db3704711
commit 6edf8314bc
8 changed files with 106 additions and 10 deletions
+13
View File
@@ -31,6 +31,19 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
must be named in `docs/roadmap.md`. The two queues had been deferred on must be named in `docs/roadmap.md`. The two queues had been deferred on
2026-10-09 with no roadmap row and were forgotten; development.md says so. 2026-10-09 with no roadmap row and were forgotten; development.md says so.
### Fixed
Two gaps a comparison of every mockup with the panel found (the rest
matches):
- **Server › Users, the user form:** with *All outbound domains* (or *All
inbound domains*) ticked, that list's single domains are disabled, as the
mockup draws them - the server ignored them already, which the help line
says. panel.js does it; without scripts they stay clickable.
- **Server › Backup, Import a domain:** "the domain's settings" links to
Outbound › Domains, where each domain's settings are, as the mockup's link
does.
## [2.15.0] - 2026-10-18 ## [2.15.0] - 2026-10-18
The two proposals the security audit left as product choices. Migration The two proposals the security audit left as product choices. Migration
@@ -431,7 +431,7 @@ func TestUserFormAssignsBothDirections(t *testing.T) {
body := send(h.HandleUserEdit, &root, "GET", "/server/users/"+idStr(other.ID), map[string]string{"uid": idStr(other.ID)}, nil).Body.String() body := send(h.HandleUserEdit, &root, "GET", "/server/users/"+idStr(other.ID), map[string]string{"uid": idStr(other.ID)}, nil).Body.String()
for _, want := range []string{ for _, want := range []string{
`name="all_domains"`, `name="all_inbound_domains"`, "out.example.com", `name="all_domains"`, `name="all_inbound_domains"`, "out.example.com",
`name="inbound_domain_ids" value="` + idStr(in.ID) + `" checked`, `name="inbound_domain_ids" value="` + idStr(in.ID) + `" data-single-in checked`,
} { } {
if !strings.Contains(body, want) { if !strings.Contains(body, want) {
t.Errorf("the user form is missing %q", want) t.Errorf("the user form is missing %q", want)
+1 -1
View File
@@ -17,7 +17,7 @@ func TestPanelJS(t *testing.T) {
if err != nil { if err != nil {
t.Skip("node is not installed") t.Skip("node is not installed")
} }
for _, script := range []string{"poll_test.js", "unsaved_test.js", "filename_test.js"} { for _, script := range []string{"poll_test.js", "unsaved_test.js", "filename_test.js", "alldomains_test.js"} {
t.Run(script, func(t *testing.T) { t.Run(script, func(t *testing.T) {
out, err := exec.Command(node, filepath.Join("testdata", script), filepath.Join("static", "panel.js")).CombinedOutput() out, err := exec.Command(node, filepath.Join("testdata", script), filepath.Join("static", "panel.js")).CombinedOutput()
if err != nil { if err != nil {
+8 -3
View File
@@ -59,6 +59,7 @@ func TestBackupHasBothFormsAndTheEncryptionHooks(t *testing.T) {
`name="password"`, `name="password_confirm"`, `minlength="12"`, `placeholder="at least 12 characters"`, `name="password"`, `name="password_confirm"`, `minlength="12"`, `placeholder="at least 12 characters"`,
`name="file"`, `accept=".json,.spde,application/json"`, `required data-import-file`, `name="file"`, `accept=".json,.spde,application/json"`, `required data-import-file`,
`data-import-password-fields`, `name="import_password"`, `<div class="box" id="import">`, `data-import-password-fields`, `name="import_password"`, `<div class="box" id="import">`,
`The export itself is made on the <a href="/outbound/domains">domain's settings</a>.`,
) )
pageLacks(t, "Backup", out, `notification`) pageLacks(t, "Backup", out, `notification`)
} }
@@ -157,8 +158,8 @@ func TestUserFormShowsWhatIsStoredUnderTheHandlersFieldNames(t *testing.T) {
`name="password" type="password" autocomplete="new-password">`, `name="password" type="password" autocomplete="new-password">`,
`Leave empty to keep the current one.`, `Leave empty to keep the current one.`,
`<select id="role" name="role" data-global-role="global">`, `<option value="domain" selected>`, `<option value="global">`, `<select id="role" name="role" data-global-role="global">`, `<option value="domain" selected>`, `<option value="global">`,
`name="all_domains" value="1" checked`, `name="domain_ids" value="1">`, `name="domain_ids" value="3">`, `name="all_domains" value="1" data-all-out checked`, `name="domain_ids" value="1" data-single-out>`, `name="domain_ids" value="3" data-single-out>`,
`name="all_inbound_domains" value="1">`, `name="inbound_domain_ids" value="1">`, `name="inbound_domain_ids" value="2" checked`, `name="all_inbound_domains" value="1" data-all-in>`, `name="inbound_domain_ids" value="1" data-single-in>`, `name="inbound_domain_ids" value="2" data-single-in checked`,
`>Save user</button>`, `href="/server/users">Cancel</a>`, `>Save user</button>`, `href="/server/users">Cancel</a>`,
) )
pageLacks(t, "User", out, `name="password" type="password" autocomplete="new-password" required`, `disabled`, pageLacks(t, "User", out, `name="password" type="password" autocomplete="new-password" required`, `disabled`,
@@ -194,7 +195,11 @@ func TestUserFormOfTheOnlyGlobalUser(t *testing.T) {
// domains is marked, so panel.js can hide the lists while the role is Global. // domains is marked, so panel.js can hide the lists while the role is Global.
func TestUserFormMarksTheListsTheGlobalRoleHides(t *testing.T) { func TestUserFormMarksTheListsTheGlobalRoleHides(t *testing.T) {
out := renderSignedIn(t, "user", userFixture()) out := renderSignedIn(t, "user", userFixture())
pageHas(t, "User", out, `<select id="role" name="role" data-global-role="global">`) pageHas(t, "User", out, `<select id="role" name="role" data-global-role="global">`,
// panel.js disables a list's single domains while its All is ticked
// (testdata/alldomains_test.js).
`name="all_domains" value="1" data-all-out`, `name="domain_ids" value="`, `data-single-out`,
`name="all_inbound_domains" value="1" data-all-in`, `data-single-in`)
if n := strings.Count(out, `<div class="box" data-domain-pick>`); n != 2 { if n := strings.Count(out, `<div class="box" data-domain-pick>`); n != 2 {
t.Errorf("%d boxes are marked data-domain-pick, want the outbound and the inbound list", n) t.Errorf("%d boxes are marked data-domain-pick, want the outbound and the inbound list", n)
} }
+22
View File
@@ -190,6 +190,24 @@
target: "[data-domain-pick]", target: "[data-domain-pick]",
visible: function (el) { return el.value !== el.dataset.globalRole; } visible: function (el) { return el.value !== el.dataset.globalRole; }
}, },
// The user form: with "All outbound domains" (or inbound) ticked the
// single domains of that list are ignored, and are shown disabled as the
// mockup user.html draws them. A disabled box is not submitted (HTML
// Living Standard, form-control-infrastructure.html#constructing-entry-list),
// which changes nothing: the server ignores them with All. Without this
// script they stay clickable, and the help line says they are ignored.
{
match: "input[data-all-out]",
target: "input[data-single-out]",
visible: function (el) { return !el.checked; },
disable: true
},
{
match: "input[data-all-in]",
target: "input[data-single-in]",
visible: function (el) { return !el.checked; },
disable: true
},
{ {
match: "input[data-encrypt-toggle]", match: "input[data-encrypt-toggle]",
target: "[data-encrypt-fields]", target: "[data-encrypt-fields]",
@@ -222,6 +240,10 @@
// A block can be several elements (the user form's two lists of domains). // A block can be several elements (the user form's two lists of domains).
var show = rule.visible(control); var show = rule.visible(control);
form.querySelectorAll(rule.target).forEach(function (target) { form.querySelectorAll(rule.target).forEach(function (target) {
if (rule.disable) {
target.disabled = !show;
return;
}
// hidden alone does not hide an element whose own stylesheet gives it a // hidden alone does not hide an element whose own stylesheet gives it a
// display (Bulma's .box), so Bulma's is-hidden helper goes with it. // display (Bulma's .box), so Bulma's is-hidden helper goes with it.
// HTML Living Standard, rendering.html#hidden-elements: [hidden] is // HTML Living Standard, rendering.html#hidden-elements: [hidden] is
+1 -1
View File
@@ -34,7 +34,7 @@
<div class="column is-6"> <div class="column is-6">
{{template "box_open" .Import}} {{template "box_open" .Import}}
{{template "body_open"}} {{template "body_open"}}
<p class="mb-3">A domain export from another instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the domain's settings page.</p> <p class="mb-3">A domain export from another instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the <a href="/outbound/domains">domain's settings</a>.</p>
<form class="sp-form" method="post" action="{{.ImportAction}}" enctype="multipart/form-data"> <form class="sp-form" method="post" action="{{.ImportAction}}" enctype="multipart/form-data">
<div class="field"><label class="label" for="importfile">Domain export file</label> <div class="field"><label class="label" for="importfile">Domain export file</label>
<div class="file has-name is-fullwidth"><label class="file-label"><input class="file-input" id="importfile" name="file" type="file" accept=".json,.spde,application/json" required data-import-file><span class="file-cta"><i class="ti ti-upload"></i><span class="file-label ml-2">Choose a file…</span></span><span class="file-name">No file chosen</span></label></div></div> <div class="file has-name is-fullwidth"><label class="file-label"><input class="file-input" id="importfile" name="file" type="file" accept=".json,.spde,application/json" required data-import-file><span class="file-cta"><i class="ti ti-upload"></i><span class="file-label ml-2">Choose a file…</span></span><span class="file-name">No file chosen</span></label></div></div>
+4 -4
View File
@@ -34,16 +34,16 @@
<div class="column is-6"> <div class="column is-6">
{{template "box_open" .Outbound}} {{template "box_open" .Outbound}}
{{template "body_open"}} {{template "body_open"}}
<div class="field"><label class="checkbox"><input type="checkbox" name="all_domains" value="1"{{if .AllOut}} checked{{end}}> <strong>All outbound domains</strong>, including ones added later</label></div>{{range .OutDomains}} <div class="field"><label class="checkbox"><input type="checkbox" name="all_domains" value="1" data-all-out{{if .AllOut}} checked{{end}}> <strong>All outbound domains</strong>, including ones added later</label></div>{{range .OutDomains}}
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" value="{{.ID}}"{{if .Checked}} checked{{end}}> {{.Name}}</label></div>{{end}} <div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" value="{{.ID}}" data-single-out{{if .Checked}} checked{{end}}> {{.Name}}</label></div>{{end}}
<p class="help">With <em>All</em> ticked the single domains are ignored.</p> <p class="help">With <em>All</em> ticked the single domains are ignored.</p>
{{template "body_close"}} {{template "body_close"}}
{{template "box_close"}} {{template "box_close"}}
{{if .ShowInbound}} {{if .ShowInbound}}
{{template "box_open" .Inbound}} {{template "box_open" .Inbound}}
{{template "body_open"}} {{template "body_open"}}
<div class="field"><label class="checkbox"><input type="checkbox" name="all_inbound_domains" value="1"{{if .AllIn}} checked{{end}}> <strong>All inbound domains</strong>, including ones added later</label></div>{{range .InDomains}} <div class="field"><label class="checkbox"><input type="checkbox" name="all_inbound_domains" value="1" data-all-in{{if .AllIn}} checked{{end}}> <strong>All inbound domains</strong>, including ones added later</label></div>{{range .InDomains}}
<div class="field"><label class="checkbox"><input type="checkbox" name="inbound_domain_ids" value="{{.ID}}"{{if .Checked}} checked{{end}}> {{.Name}}</label></div>{{end}} <div class="field"><label class="checkbox"><input type="checkbox" name="inbound_domain_ids" value="{{.ID}}" data-single-in{{if .Checked}} checked{{end}}> {{.Name}}</label></div>{{end}}
<p class="help">Granted separately from outbound: the same name on both lists is two assignments. The <em>domain</em> role needs at least one domain, or <em>All</em>, on either list.</p> <p class="help">Granted separately from outbound: the same name on both lists is two assignments. The <em>domain</em> role needs at least one domain, or <em>All</em>, on either list.</p>
{{template "body_close"}} {{template "body_close"}}
{{template "box_close"}} {{template "box_close"}}
+56
View File
@@ -0,0 +1,56 @@
// The user form (Server › Users): with "All outbound domains" ticked the
// single outbound domains are disabled, as the mockup user.html draws them, and
// enabled again when it is unticked; the inbound list follows its own All the
// same way, and neither touches the other. Run by TestPanelJS
// (paneljs_test.go): node alldomains_test.js <panel.js>. Exits non-zero with
// what went wrong.
"use strict";
const fs = require("fs");
const vm = require("vm");
const failures = [];
function check(cond, what) {
if (!cond) failures.push(what);
}
const listeners = {};
const box = (attr, checked) => ({ attr, checked, disabled: false, matches: (sel) => sel === "input[" + attr + "]", closest: () => form });
const allOut = box("data-all-out", true);
const allIn = box("data-all-in", false);
const singlesOut = [box("data-single-out", true), box("data-single-out", false)];
const singlesIn = [box("data-single-in", true)];
const form = {
querySelectorAll: (sel) =>
sel === "input[data-single-out]" ? singlesOut : sel === "input[data-single-in]" ? singlesIn : [],
};
const document = {
hidden: false,
addEventListener: (type, fn) => (listeners[type] = listeners[type] || []).push(fn),
body: { addEventListener() {} },
getElementById: () => null,
querySelectorAll: (sel) =>
sel === "input[data-all-out]" ? [allOut] : sel === "input[data-all-in]" ? [allIn] : [],
};
vm.runInNewContext(fs.readFileSync(process.argv[2], "utf8"), {
document, window: { addEventListener() {} }, htmx: { config: {}, ajax() {} }, setTimeout, clearTimeout, Date, Object, Math, console,
});
const fire = (type, ev) => (listeners[type] || []).forEach((fn) => fn(ev));
fire("DOMContentLoaded", {});
check(singlesOut.every((b) => b.disabled), "at load, with All outbound ticked, the single outbound domains are not disabled");
check(singlesIn.every((b) => !b.disabled), "at load, with All inbound unticked, a single inbound domain is disabled");
allOut.checked = false;
fire("change", { target: allOut });
check(singlesOut.every((b) => !b.disabled), "unticking All outbound leaves the single outbound domains disabled");
check(singlesOut[0].checked && !singlesOut[1].checked, "unticking All outbound changed which single domains are ticked");
allIn.checked = true;
fire("change", { target: allIn });
check(singlesIn.every((b) => b.disabled), "ticking All inbound does not disable the single inbound domains");
check(singlesOut.every((b) => !b.disabled), "ticking All inbound disabled the outbound list");
if (failures.length) {
console.error(failures.join("\n"));
process.exit(1);
}