fix(panel): P3 optional items — urlquery, aria-live, confirm-without-JS note

- Deliveries fragment/pagination links urlquery-encode the domain/app
  filters instead of splicing them into the query string raw.
- The four polled regions (deliveries rows, status, mail queue, system
  log) carry aria-live="polite" so a screen reader announces refreshes.
- security.md accepted risks note that data-confirm prompts are
  JavaScript-only and why that is acceptable.

docs/plans/code-review.md P3 fully checked off; CHANGELOG updated.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-13 22:54:31 +03:00
parent 0570608738
commit aab35f0679
7 changed files with 34 additions and 8 deletions
+12
View File
@@ -62,6 +62,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
`field_values`, `credentials_fields`) instead of copy-pasted blocks. No `field_values`, `credentials_fields`) instead of copy-pasted blocks. No
behaviour or visible change. behaviour or visible change.
- panel (GUI, accessibility): the Deliveries fragment's `hx-get` and pagination
links now `urlquery`-encode the `domain`/`app` filters instead of splicing
them into the query string raw. The four polled regions (deliveries rows,
status, mail queue, system log) carry `aria-live="polite"` so a screen
reader announces the refreshed content.
- docs: [security.md](docs/security.md) accepted risks now note that
`data-confirm` prompts on destructive forms are JavaScript-only — with
JavaScript disabled the form submits immediately, the same as before the
prompts existed — and why that is acceptable (the prompt is a mis-click
guard, not an authorization boundary).
- docs: full-tree review plan ([docs/plans/code-review.md](docs/plans/code-review.md)) - docs: full-tree review plan ([docs/plans/code-review.md](docs/plans/code-review.md))
— architecture, quality, GUI, tests, licence; P0 is domain-admin send-log — architecture, quality, GUI, tests, licence; P0 is domain-admin send-log
authorization. Roadmap queues that plan ahead of inbound-relay and records authorization. Roadmap queues that plan ahead of inbound-relay and records
+1 -1
View File
@@ -551,7 +551,7 @@ roles, and security.md no longer calls the panel single-user.
domain delete. Done as a confirm page (`GET/POST /users/{uid}/delete`), domain delete. Done as a confirm page (`GET/POST /users/{uid}/delete`),
matching `domain_delete.html`. matching `domain_delete.html`.
- [x] DNS field partial; settings credentials partial. - [x] DNS field partial; settings credentials partial.
- [ ] Optional: `urlquery` on deliveries fragment params; `aria-live` on - [x] Optional: `urlquery` on deliveries fragment params; `aria-live` on
polled regions; confirm-without-JS note next to the CSRF accepted risks. polled regions; confirm-without-JS note next to the CSRF accepted risks.
**Done when:** a rate-limit validation error is visually an error; user delete **Done when:** a rate-limit validation error is visually an error; user delete
+13
View File
@@ -127,6 +127,19 @@ deferred item from the roadmap.
either: code executing in the panel's origin sends the request itself — either: code executing in the panel's origin sends the request itself —
against that, `html/template` auto-escaping and CSP do the work, which is why against that, `html/template` auto-escaping and CSP do the work, which is why
templates must contain no inline scripts and no inline styles. templates must contain no inline scripts and no inline styles.
- **Destructive-action confirmation (`data-confirm`) is JavaScript-only.**
Delete, regenerate-password, and clear-rate-limit forms carry a
`data-confirm` prompt handled entirely in
[panel.js](../internal/web/view/static/panel.js); with JavaScript disabled
or blocked the form submits immediately, exactly as it did before the
prompts existed. Accepted deliberately: the prompt is a mis-click guard,
not an authorization boundary — the same origin check and session/RBAC
gate every one of these `POST`s whether or not JavaScript ran. Progressive
enhancement means the panel must work with JavaScript off; a
server-rendered confirmation step would need a second page (or a `?confirm=1`
round trip) for every one of these forms, which is what
[`user_delete.html`](../internal/web/view/templates/user_delete.html) and
`domain_delete.html` already do for the two highest-blast-radius deletes.
- **Encrypting backups and exports is an option, not the default.** With the - **Encrypting backups and exports is an option, not the default.** With the
checkbox cleared the file downloads in the clear, as in 1.0. Otherwise an checkbox cleared the file downloads in the clear, as in 1.0. Otherwise an
operator with nowhere to keep a password would lose the ability to take a operator with nowhere to keep a password would lose the ability to take a
@@ -1,5 +1,6 @@
{{define "deliveries_rows"}} {{define "deliveries_rows"}}
<div id="deliveries-rows" data-poll hx-get="/deliveries/rows?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.Page}}" <div id="deliveries-rows" data-poll aria-live="polite"
hx-get="/deliveries/rows?domain={{.FilterDomain | urlquery}}&app={{.FilterApp | urlquery}}&p={{.Page}}"
hx-trigger="load" hx-swap="outerHTML"> hx-trigger="load" hx-swap="outerHTML">
{{if .Rows}} {{if .Rows}}
<table> <table>
@@ -14,15 +15,15 @@
<td>{{.To}}</td> <td>{{.To}}</td>
<td class="subject"><span title="{{.Subject}}">{{.Subject}}</span></td> <td class="subject"><span title="{{.Subject}}">{{.Subject}}</span></td>
<td class="status"><span class="st st-{{.Level}}">{{.Status}}</span></td> <td class="status"><span class="st st-{{.Level}}">{{.Status}}</span></td>
<td class="actions"><a href="/deliveries/{{.ID}}?domain={{$.FilterDomain}}&app={{$.FilterApp}}&p={{$.Page}}">Details</a></td> <td class="actions"><a href="/deliveries/{{.ID}}?domain={{$.FilterDomain | urlquery}}&app={{$.FilterApp | urlquery}}&p={{$.Page}}">Details</a></td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
<p class="muted"> <p class="muted">
Page {{.Page}}{{if .LastPage}} of {{.LastPage}}{{end}} Page {{.Page}}{{if .LastPage}} of {{.LastPage}}{{end}}
{{if .HasPrev}} &middot; <a href="/deliveries?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.PrevPage}}">&larr; Newer</a>{{end}} {{if .HasPrev}} &middot; <a href="/deliveries?domain={{.FilterDomain | urlquery}}&app={{.FilterApp | urlquery}}&p={{.PrevPage}}">&larr; Newer</a>{{end}}
{{if .HasNext}} &middot; <a href="/deliveries?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.NextPage}}">Older &rarr;</a>{{end}} {{if .HasNext}} &middot; <a href="/deliveries?domain={{.FilterDomain | urlquery}}&app={{.FilterApp | urlquery}}&p={{.NextPage}}">Older &rarr;</a>{{end}}
</p> </p>
{{else}} {{else}}
<p class="muted">No messages logged yet.</p> <p class="muted">No messages logged yet.</p>
@@ -1,5 +1,5 @@
{{define "mail_queue_body"}} {{define "mail_queue_body"}}
<div id="mail-queue-body" data-poll hx-get="/mail-queue/body" hx-trigger="load" hx-swap="outerHTML"> <div id="mail-queue-body" data-poll aria-live="polite" hx-get="/mail-queue/body" hx-trigger="load" hx-swap="outerHTML">
{{if .Error}}<p class="error">{{.Error}}</p>{{end}} {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<span class="code">{{if .Output}}{{.Output}}{{else}}Queue is empty.{{end}}</span> <span class="code">{{if .Output}}{{.Output}}{{else}}Queue is empty.{{end}}</span>
</div> </div>
+1 -1
View File
@@ -1,5 +1,5 @@
{{define "status_body"}} {{define "status_body"}}
<div id="status-body" data-poll hx-get="/status/fragment" hx-trigger="load" hx-swap="outerHTML"> <div id="status-body" data-poll aria-live="polite" hx-get="/status/fragment" hx-trigger="load" hx-swap="outerHTML">
<div class="card" id="overall"> <div class="card" id="overall">
<h2>Overall <span class="st st-{{.OverallStatus}}">{{.OverallStatus}}</span></h2> <h2>Overall <span class="st st-{{.OverallStatus}}">{{.OverallStatus}}</span></h2>
<p class="muted">{{.OverallHeading}}</p> <p class="muted">{{.OverallHeading}}</p>
@@ -1,5 +1,5 @@
{{define "system_log_body"}} {{define "system_log_body"}}
<div id="system-log-body" data-poll hx-get="/system-log/body" hx-trigger="load" hx-swap="outerHTML"> <div id="system-log-body" data-poll aria-live="polite" hx-get="/system-log/body" hx-trigger="load" hx-swap="outerHTML">
{{if .Error}}<p class="error">{{.Error}}</p>{{end}} {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<span class="code">{{if .Lines}}{{range .Lines}}{{.}} <span class="code">{{if .Lines}}{{range .Lines}}{{.}}
{{end}}{{else}}No log lines yet.{{end}}</span> {{end}}{{else}}No log lines yet.{{end}}</span>