panel-redesign: Server pages and Help on the kit; the help drawer is gone

Stage 2, step 6. Written by Sonnet, reviewed by Opus.

  - Six pages named after their mockups: system-log, backup, users,
    user, user-delete, help, with typed page data (view/server.go,
    help.go), fixtures and tests. The old templates of the six are gone
    (user_form.html, encrypt_fields.html deleted, the others rewritten)
    and their names are off legacy_pages.txt, which now holds mail_queue
    alone.
  - Help is six help_topic blocks (checks, certificate, rdns, dns, apps,
    limits); a test renders every fixture page and fails on a Help link
    without a topic. The help drawer (help_drawer.html and its includes
    in layout_legacy.html) is removed; its rules in legacy.css go with
    the next step.
  - The user form sets the user's e-mail (plan, Account e-mail): create
    and update, validated, and a changed address resyncs DMARC for a
    user whose default is the account e-mail - through the helper the
    Account profile now shares.
  - Handlers pass typed data; routes, POST targets, field names,
    validation and authorization are unchanged. A test loads the user
    form for four reach combinations and for the only global user, posts
    it back untouched and finds the user unchanged.

Fixed on the way
  - The user delete confirmation said the domains were not affected. A
    domain that follows the user's default report address is left with
    none; the page names those domains now.
  - A refused backup lost the encryption choice and printed a literal
    <code> element in its message.

Differences from the mockups, for the owner:
  - Help has no DMARC and no Inbound topic: its outline fixes two boxes
    of three topics, although other mockups link to #dmarc and #inbound.
    Until the owner decides, Account's Help link goes to #dns and the
    inbound MX box has none;
  - system log: newest line first, as drawn; "refreshes on its own" (5 s
    active, 30 s idle); only status=, warning: and error: lines are
    coloured;
  - backup: the file field is a plain file input; the encryption box is
    ticked, as drawn and as on the domain export, so a plain download
    means unticking it first;
  - users: an E-mail column; the flash does not name the user;
  - user form: no lead; the domain lists are no longer hidden by script
    when Global is chosen (the server ignores them for that role).

Evidence
  - docs/assets/panel-redesign/evidence/system-log.png, backup.png,
    users.png, user.png, user-delete.png, help.png: 1440 px wide,
    headless Edge, real templates served locally with their fixtures -
    NOT from the container (no Docker on this machine).
  - panel/audit.js on the six pages at 1440 and 1280: 720 texts checked,
    problems: [].
  - The system log's polling swap was not exercised in a browser; it
    uses the mechanism of Overview and Health.
  - go test -count=1 ./internal/web/... ./cmd/panel
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view
      ok  cmd/panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-10 02:14:25 +03:00
co-authored by Claude Opus 5.5
parent 610023ef6a
commit fa696efa8b
37 changed files with 1896 additions and 537 deletions
+43 -38
View File
@@ -1,43 +1,48 @@
{{/* Backup (mockup backup.html): the whole instance in one archive, and one
domain brought in from another SelfPost. Two boxes, each its own form and
its own POST; a refusal of either comes back as the flash above them. The
password fields of the full backup are shown when the box is ticked (panel.js
reads data-encrypt-toggle / data-encrypt-fields); the import asks for a
password only for an encrypted file (data-import-file /
data-import-password-fields). Without scripts all of them are visible. */}}
{{define "content"}}
<h1>Backup &amp; migration</h1>
{{template "page_head" .Head}}
{{if .Flash}}{{template "flash" .Flash}}{{end}}
<div class="card">
<h2>Full backup</h2>
<p class="muted">Download a self-contained backup of the whole instance —
<code>data/</code> (database, DKIM keys, application credentials, and the
Postfix queue), <code>docker-compose.yml</code>, <code>.env</code>, and
<code>certs/</code>. Extract it into an empty project directory on a new
machine, adjust hostname or proxy settings if needed, and start a container of
the <strong>same SelfPost version</strong> before first boot. The reverse-proxy
vhost is not included — set that up separately on the new host.</p>
<p class="muted"><strong>The backup file is a secret</strong> (it contains
private keys, TLS material, and credentials). Store and transfer it securely and delete it
once the restore succeeds. Encrypting it below is the simplest way to do that:
the download is then a <code>.spbk</code> file (SelfPost backup) that only
the password opens.</p>
{{if .BackupErr}}<p class="error">{{.BackupErr}}</p>{{end}}
<form method="post" action="/server/backup">
{{template "encryptfields" .}}
<button type="submit">Download full backup</button>
</form>
</div>
<div class="card">
<h2>Import a domain</h2>
<p class="muted">Move a single domain here from another SelfPost instance using
a domain export file (from that domain's page) — plain <code>.json</code> or
encrypted <code>.spde</code> (SelfPost domain export). Its DKIM key and
application passwords come across, so the published DNS record needs no
change. The export file is a secret, like a full backup.</p>
{{if .ImportErr}}<p class="error">{{.ImportErr}}</p>{{end}}
<form method="post" action="/server/backup/import" enctype="multipart/form-data">
<label for="importfile">Domain export file</label>
<input id="importfile" name="file" type="file" accept=".json,.spde,application/json" required data-import-file>
<div class="encrypt-fields" data-import-password-fields>
<label for="importpw">Password</label>
<input id="importpw" name="import_password" type="password" autocomplete="off">
<div class="columns">
<div class="column is-6">
{{template "box_open" .Full}}
{{template "body_open"}}
<p class="mb-3"><code>data/</code> (database, DKIM keys, credentials, the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. Restore into an empty project directory and start the <strong>same SelfPost version</strong>. The reverse-proxy vhost is not included.</p>
<form class="sp-form" method="post" action="{{.FullAction}}">
<div class="field"><label class="checkbox"><input type="checkbox" name="encrypt" value="1"{{if .Encrypt}} checked{{end}} data-encrypt-toggle> Encrypt with a password (<code>.spbk</code>)</label></div>
<div data-encrypt-fields>
<div class="sp-pair">
<div class="field"><label class="label" for="encpw">Password</label><div class="control"><input class="input" id="encpw" name="password" type="password" autocomplete="new-password" minlength="{{.PasswordMin}}" placeholder="at least {{.PasswordMin}} characters"></div></div>
<div class="field"><label class="label" for="encpw2">Repeat password</label><div class="control"><input class="input" id="encpw2" name="password_confirm" type="password" autocomplete="new-password"></div></div>
</div>
<button type="submit">Import domain</button>
</form>
<p class="help mb-3">Keep this password: SelfPost does not store it, and the file cannot be opened without it.</p>
</div>
<button type="submit" class="button is-primary"><i class="ti ti-download"></i>Download full backup</button>
</form>
{{template "body_close"}}
{{template "box_close"}}
</div>
<div class="column is-6">
{{template "box_open" .Import}}
{{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>
<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="control"><input class="input" id="importfile" name="file" type="file" accept=".json,.spde,application/json" required data-import-file></div></div>
<div class="field" data-import-password-fields><label class="label" for="importpw">Password</label>
<div class="control"><input class="input" id="importpw" name="import_password" type="password" autocomplete="off"></div>
<p class="help">Asked only for an encrypted <code>.spde</code> file.</p></div>
<button type="submit" class="button is-primary"><i class="ti ti-file-import"></i>Import domain</button>
</form>
{{template "body_close"}}
{{template "box_close"}}
</div>
</div>
{{end}}
@@ -1,24 +0,0 @@
{{/* Password fields shared by the full-backup and domain-export forms. The
checkbox is the switch: unticked, the download keeps its historic plain
form (.tar.gz / .json); ticked, the file is sealed in a password-encrypted
envelope (.spbk = SelfPost backup, .spde = SelfPost domain export). The
fields start hidden and are revealed by panel.js — with JavaScript blocked
they are simply always visible, and the server still decides from the
checkbox alone. */}}
{{define "encryptfields"}}
<div class="encrypt">
<label class="check">
<input type="checkbox" name="encrypt" value="1" data-encrypt-toggle>
<span>Encrypt with a password</span>
</label>
<div class="encrypt-fields" data-encrypt-fields>
<label for="encpw">Password</label>
<input id="encpw" name="password" type="password" autocomplete="new-password"
minlength="{{.MinPwLen}}" placeholder="at least {{.MinPwLen}} characters">
<label for="encpw2">Repeat password</label>
<input id="encpw2" name="password_confirm" type="password" autocomplete="new-password">
<p class="muted">Keep this password: without it the file cannot be opened,
and SelfPost does not store it anywhere.</p>
</div>
</div>
{{end}}
+21 -58
View File
@@ -1,62 +1,25 @@
{{/* Help (mockup help.html): one topic per heading, stacked in two boxes, with a
menu that jumps to each. A box on another page links to the topic that
explains it by the topic's id (Box.Help). Overview is absent for a viewer
who does not see the pages it explains. */}}
{{define "content"}}
<h1>Help</h1>
{{template "page_head" .Head}}
<p class="muted">Short operator notes inside the panel — not a second copy of
the full guide. The cards keep their readings and controls; this page (and the
drawer opened from each card's «?») holds the explanations that do not belong
on the card itself.</p>
<div class="card">
<h2>On this panel</h2>
{{if .IsGlobal}}
<ul>
<li><label for="help-status">Status checks</label> — Machine, TLS, PTR,
queue</li>
<li><a href="/outbound/queue">Mail queue retries</a> — time-based, no attempt
budget (on the queue page)</li>
{{if .InboundEnabled}}
<li><a href="/inbound/domains">Inbound</a> — not mailboxes; listed recipients or any
address at the domain</li>
{{end}}
</ul>
{{end}}
<p class="muted">Domain page</p>
<ul>
<li><label for="help-dns">DNS status</label></li>
<li><label for="help-records">DKIM and SPF records</label></li>
<li><label for="help-dmarc">DMARC record</label></li>
<li><label for="help-connection">Connection settings</label></li>
<li><label for="help-apps">Applications</label></li>
<li><label for="help-domain-settings">Domain settings</label></li>
<li><label for="help-export">Export domain</label></li>
</ul>
<p class="muted">Topics marked as labels open the same text in the help
drawer. For installation, backup, and environment variables see the
<a href="{{.SourceURL}}/blob/main/docs/guide.md">operator
guide</a> in the repository.</p>
</div>
{{if .IsGlobal}}
<div class="card">
<h2>Machine</h2>
<p>CPU and memory are the container's own readings, not the host's spare
capacity. Network is a short window, not a daily total. High CPU with an empty
queue usually means something else on the box — not SelfPost being slow to
send.</p>
</div>
<div class="card">
<h2>TLS certificate</h2>
<p>Port 465 presents the certificate the reverse proxy (or the image)
mounted. The panel does not issue certificates. A warn here is expires soon;
an error is missing or unreadable, and clients will refuse submission.</p>
</div>
<div class="card">
<h2>Hostname / reverse DNS</h2>
<p>Forward-confirmed reverse DNS: the A/AAAA for
<code>SELFPOST_HOSTNAME</code> must reverse to that same name. PTR is set at
the hosting provider, not in this panel.</p>
<div class="columns">
<div class="column is-3">
{{template "side_menu" .Menu}}
</div>
<div class="column is-9">
{{if .ShowOverview}}
{{template "box_open" .Overview}}
{{range .OverviewTopics}}{{template "help_topic" .}}
{{end}}
{{template "box_close"}}
{{end}}
{{template "box_open" .Outbound}}
{{range .OutboundTopics}}{{template "help_topic" .}}
{{end}}
{{template "box_close"}}
</div>
</div>
{{end}}
{{end}}
@@ -1,161 +0,0 @@
{{/* Hidden radios drive which help pane is visible — CSS :has(), no script. */}}
{{define "help_inputs"}}
<input class="ctrl" type="radio" name="help" id="help-off" checked>
<input class="ctrl" type="radio" name="help" id="help-index">
<input class="ctrl" type="radio" name="help" id="help-status">
<input class="ctrl" type="radio" name="help" id="help-password">
<input class="ctrl" type="radio" name="help" id="help-dns">
<input class="ctrl" type="radio" name="help" id="help-records">
<input class="ctrl" type="radio" name="help" id="help-dmarc">
<input class="ctrl" type="radio" name="help" id="help-connection">
<input class="ctrl" type="radio" name="help" id="help-apps">
<input class="ctrl" type="radio" name="help" id="help-domain-settings">
<input class="ctrl" type="radio" name="help" id="help-export">
{{end}}
{{define "help_more"}}<p class="more muted"><label for="help-index">All topics</label>{{if .}} · <a href="/help">Help page</a>{{end}}</p>{{end}}
{{/* card_help — «?» on a card head; .ID is the topic slug (dns, records, …). */}}
{{define "card_help"}}
<label class="help-link" for="help-{{.ID}}" title="{{.Title}}">?</label>
{{end}}
{{define "help_drawer"}}
<label class="help-scrim" for="help-off" role="button" aria-label="Close help"></label>
<aside class="help-drawer">
<label class="help-close" for="help-off" role="button" aria-label="Close help">Close</label>
<article class="help-pane help-pane-index">
<h2>Help</h2>
<p>Short notes for the card you opened — not a second copy of the
<a href="{{.SourceURL}}/blob/main/docs/guide.md">operator guide</a>.</p>
{{if .IsGlobal}}
<p class="muted">Status</p>
<ul class="toc"><li><label for="help-status">Status checks</label></li></ul>
{{end}}
<p class="muted">Domain</p>
<ul class="toc">
<li><label for="help-password">New application password</label></li>
<li><label for="help-dns">DNS status</label></li>
<li><label for="help-records">DKIM and SPF records</label></li>
<li><label for="help-dmarc">DMARC record</label></li>
<li><label for="help-connection">Connection settings</label></li>
<li><label for="help-apps">Applications</label></li>
<li><label for="help-domain-settings">Domain settings</label></li>
<li><label for="help-export">Export domain</label></li>
</ul>
</article>
<article class="help-pane help-pane-status">
<h2>Status checks</h2>
<p>The cards keep the readings. This drawer is what used to sit under them
as paragraphs.</p>
<h2>Machine</h2>
<p>CPU and memory are the container's readings, not the host's spare
capacity. Network is a short rate window between refreshes, not a daily
total. High CPU with an empty queue usually means something else on the box
— not SelfPost being slow to send.</p>
<h2>TLS certificate</h2>
<p>Port 465 presents the certificate the reverse proxy (or the image)
mounted. The panel does not issue certificates. A warn here is expires
soon; an error is missing or unreadable, and clients will refuse
submission.</p>
<h2>Hostname / reverse DNS</h2>
<p>Forward-confirmed reverse DNS: the A/AAAA for
<code>SELFPOST_HOSTNAME</code> must reverse to that same name. PTR is set
at the hosting provider, not in this panel. Receiving networks use this
pair as a cheap reputation check.</p>
<h2>Mail queue</h2>
<p>Deferred mail is retried on a time schedule (first delay, doubling cap,
queue lifetime). There is no attempt budget — see the retry-policy card on
<em>Mail queue</em>.</p>
{{if .InboundEnabled}}
<h2>Inbound</h2>
<p>When <code>INBOUND_RELAY_ENABLE</code> is on, port 25 accepts mail for
configured domains and forwards it upstream — not to local mailboxes.
Recipients are a list or any address at the domain.</p>
{{end}}
{{template "help_more" true}}
</article>
<article class="help-pane help-pane-password">
<h2>New application password</h2>
<p>Shown <strong>once only</strong> and not stored. Copy it now — if it is
lost, regenerate a new one. The previous password stops working
immediately.</p>
{{template "help_more" true}}
</article>
<article class="help-pane help-pane-dns">
<h2>DNS status</h2>
<p>The badge is the worst of DKIM, SPF and DMARC. Results are cached a few
minutes — use <em>Re-check</em> after publishing.</p>
<p>SPF is a shallow check: the literal address only, no
<code>include:</code> or <code>redirect=</code>. Report authorization is
required only when <code>rua=</code> points at a domain this server does
not accept.</p>
{{template "help_more" true}}
</article>
<article class="help-pane help-pane-records">
<h2>DKIM and SPF records</h2>
<p>DKIM is not a secret. The selector on this page is the one this server
signs with. Merge the SPF example into an existing record if the domain
already has one — do not publish a second TXT.</p>
{{template "help_more" true}}
</article>
<article class="help-pane help-pane-dmarc">
<h2>DMARC record</h2>
<p><code>p=none</code> does not affect delivery. Tighten to
<code>p=quarantine</code> then <code>p=reject</code> once reports look
clean. The report address is set under Domain settings (or the Settings
default).</p>
{{template "help_more" true}}
</article>
<article class="help-pane help-pane-connection">
<h2>Connection settings</h2>
<p>Same host for every domain. Authenticate with an application login from
this page. Auth is required on every port. The password is shown once at
create or regenerate.</p>
<p>465 is implicit TLS; 587 is STARTTLS submission when that port is
enabled.</p>
{{template "help_more" true}}
</article>
<article class="help-pane help-pane-apps">
<h2>Applications</h2>
<p>SASL logins for this domain. Login is unique across domains; letters,
digits, <code>.</code>, <code>-</code> and <code>_</code>. The password is
shown once.</p>
<p>Address mode is which From addresses this application may use: any
address of the domain, or a fixed list. Client IP allow-list, when enabled,
restricts which client addresses may <em>submit</em> as this application;
when off, any client IP is allowed. It is checked in the journal-milter at
<code>MAIL FROM</code>, alongside the level-2 limit and fail-open like it,
so it narrows sending rather than blocking the SASL login itself. The
level-2 rate limit <em>overrides</em> the
domain limit for that application — you can set a ceiling above or below
the domain (still ≤ level&nbsp;1). Independent of the client IP allow-list.</p>
{{template "help_more" true}}
</article>
<article class="help-pane help-pane-domain-settings">
<h2>Domain settings</h2>
<p>Aggregate reports (<code>rua=</code>) inherit the Settings default, or
you override them per domain. Level 2 is an optional ceiling for all
senders on this domain; it must be ≤ level 1. Each application may override
that domain limit (higher or lower, still ≤ level&nbsp;1).</p>
{{template "help_more" true}}
</article>
<article class="help-pane help-pane-export">
<h2>Export domain</h2>
<p>The file is a secret: it carries the DKIM key and application
passwords, so published DNS does not have to change on the other instance.
Transfer it securely, or encrypt it as <code>.spde</code>.</p>
{{template "help_more" true}}
</article>
</aside>
{{end}}
@@ -26,7 +26,6 @@
<script src="/static/panel.js" defer></script>
</head>
<body>
{{if .User}}{{template "help_inputs" .}}{{end}}
{{/* Two columns: the navigation on the left and the page beside it, centred
together (see .shell in legacy.css). The signed-out pages have no navigation
and the shell holds their card alone. */}}
@@ -55,7 +54,6 @@
</footer>
</main>
</div>
{{if .User}}{{template "help_drawer" .}}{{end}}
</body>
</html>{{end}}
+5 -9
View File
@@ -1,12 +1,8 @@
{{/* Raw mail.log lines, long by nature: the whole column rather than the
reading measure (see the "wide" block in layout.html). */}}
{{define "wide"}}wide{{end}}
{{/* System log (mockup system-log.html): the tail of mail.log in one box. The box
is the polled part (system_log_body.html), so the page and its fragment
draw it from the same block. */}}
{{define "content"}}
<h1>System log</h1>
{{template "page_head" .Head}}
<div class="card">
<h2>Recent log entries</h2>
{{template "system_log_body" .}}
</div>
{{template "system_log_body" .}}
{{end}}
@@ -1,7 +1,6 @@
{{define "system_log_body"}}
<div id="system-log-body" data-poll aria-live="polite" hx-get="/server/log/fragment" hx-trigger="load" hx-swap="outerHTML">
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<span class="code">{{if .Lines}}{{range .Lines}}{{.}}
{{end}}{{else}}No log lines yet.{{end}}</span>
</div>
{{end}}
{{/* The polled part of the System log, in the page and in its fragment (GET
/server/log/fragment) alike: the box itself polls (.Log.Poll), so the
response replaces it whole. With no lines to show it says why instead. */}}
{{define "system_log_body"}}{{template "box_open" .Log}}
{{if .Lines}}{{template "log_pane" .Lines}}{{else}}{{template "empty_state" .Empty}}{{end}}
{{template "box_close"}}{{end}}
+53
View File
@@ -0,0 +1,53 @@
{{/* The user form (mockup user.html): one form that creates a panel user and
edits one. The account is the left column; on the right the domains the user
reaches, outbound and inbound as two lists, each with its All. The lists stay
in view whatever the role — the server ignores them for the global role. A
blank password keeps the current one. */}}
{{define "content"}}
{{template "page_head" .Head}}
{{if .Flash}}{{template "flash" .Flash}}{{end}}
<form class="sp-form" method="post" action="{{.Action}}">
<div class="columns">
<div class="column is-6">
{{template "box_open" .Account}}
{{template "body_open"}}
<div class="field"><label class="label" for="username">Username</label><div class="control"><input class="input" id="username" name="username" value="{{.Username}}" autocomplete="username" autocapitalize="none" spellcheck="false" required></div></div>
<div class="field"><label class="label" for="email">E-mail</label><div class="control"><input class="input" id="email" name="email" type="email" value="{{.Email}}" autocomplete="off"></div>
<p class="help">The user's own address. They can change it under Account, and can choose it there as the address that receives DMARC reports.</p></div>
<div class="field"><label class="label" for="password">Password</label><div class="control"><input class="input" id="password" name="password" type="password" autocomplete="new-password"{{if .NewUser}} required{{end}}></div>
<p class="help">{{if .NewUser}}At least {{.PasswordMin}} characters.{{else}}Leave empty to keep the current one.{{end}}</p></div>
<div class="field"><label class="label" for="role">Role</label>
{{if .RoleLocked}}<input type="hidden" name="role" value="{{.Role}}">{{end}}
<div class="control"><div class="select is-fullwidth"><select id="role" name="role"{{if .RoleLocked}} disabled{{end}}>{{$sel := .Role}}{{range .Roles}}
<option value="{{.Value}}"{{if eq .Value $sel}} selected{{end}}>{{.Label}}</option>{{end}}
</select></div></div>
<p class="help">Everyone here is an administrator; the role is the reach. <em>Global</em> also sees Server and is the only one who adds or deletes domains. The only global user cannot be changed to <em>domain</em> or deleted.</p></div>
{{template "body_close"}}
{{template "box_close"}}
</div>
<div class="column is-6">
{{template "box_open" .Outbound}}
{{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="domain_ids" value="{{.ID}}"{{if .Checked}} checked{{end}}> {{.Name}}</label></div>{{end}}
<p class="help">With <em>All</em> ticked the single domains are ignored.</p>
{{template "body_close"}}
{{template "box_close"}}
{{if .ShowInbound}}
{{template "box_open" .Inbound}}
{{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="inbound_domain_ids" value="{{.ID}}"{{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>
{{template "body_close"}}
{{template "box_close"}}
{{end}}
</div>
</div>
<div class="buttons">
<button type="submit" class="button is-primary">{{.Submit}}</button>
<a class="button is-text" href="{{.CancelHref}}">Cancel</a>
</div>
</form>
{{end}}
+19 -12
View File
@@ -1,16 +1,23 @@
{{/* Delete a panel user (mockup user-delete.html): what goes with the user, the
one button that does it, and the way back to the user's form. */}}
{{define "content"}}
<h1>Delete {{.TargetUsername}}</h1>
{{template "page_head" .Head}}
{{template "back_link" (back (printf "/server/users/%d" .TargetID) (printf "Back to %s" .TargetUsername))}}
<div class="card narrow">
<h2>Confirm deletion</h2>
<p>You are about to delete the panel user <strong>{{.TargetUsername}}</strong>.
This cannot be undone; a signed-in session for this user stops working
immediately.</p>
<form method="post" action="/server/users/{{.TargetID}}/delete">
<button type="submit" class="danger">Delete {{.TargetUsername}}</button>
</form>
<div class="columns">
<div class="column is-7">
{{template "box_open" .Danger}}
{{template "body_open"}}
<p class="mb-3">{{template "inline" .Intro}}</p>
{{template "confirm_list" .Consequences}}
<p class="sp-muted mb-4">{{template "inline" .Note}}</p>
<form method="post" action="{{.Action}}">
<div class="buttons">
<button type="submit" class="button is-danger"><i class="ti ti-trash"></i>{{.Confirm}}</button>
<a class="button is-text" href="{{.KeepHref}}">Keep it</a>
</div>
</form>
{{template "body_close"}}
{{template "box_close"}}
</div>
</div>
{{end}}
@@ -1,69 +0,0 @@
{{define "content"}}
<h1>{{if .IsEdit}}Edit user{{else}}Create user{{end}}</h1>
{{template "back_link" (back "/server/users" "Back to users")}}
<div class="card narrow">
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" action="{{if .IsEdit}}/server/users/{{.UserID}}{{else}}/server/users/new{{end}}">
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username"
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
<label for="password">Password{{if .IsEdit}} (leave empty to keep){{end}}</label>
<input id="password" name="password" type="password" autocomplete="new-password" {{if not .IsEdit}}required{{end}}>
<label for="role">Role</label>
{{if .LastGlobalLocked}}<input type="hidden" name="role" value="{{.FormRole}}">{{end}}
<select id="role" name="role" data-global-role="{{.GlobalRole}}"{{if .LastGlobalLocked}} disabled{{end}}>
<option value="domain" {{if eq .FormRole "domain"}}selected{{end}}>Domain administrator</option>
<option value="global" {{if eq .FormRole "global"}}selected{{end}}>Global administrator</option>
</select>
{{if .LastGlobalLocked}}<p class="muted">The only global administrator cannot be demoted.</p>{{end}}
{{/* Outbound and inbound domains are assigned separately; a domain
administrator needs at least one on either list. "All" also covers
domains added later — with it ticked the rows below are ignored. */}}
<div id="domain-pick" data-domain-pick>
<fieldset>
<legend>Outbound domains</legend>
<p class="muted">A domain administrator needs at least one outbound or inbound domain.</p>
<label class="check">
<input type="checkbox" name="all_domains" value="1" {{if .FormAllDomains}}checked{{end}}>
All, including domains added later
</label>
{{range .Domains}}
<label class="check">
<input type="checkbox" name="domain_ids" value="{{.ID}}" {{if index $.FormDomains .ID}}checked{{end}}>
{{.Name}}
</label>
{{end}}
</fieldset>
{{if .InboundEnabled}}
<fieldset>
<legend>Inbound domains</legend>
<label class="check">
<input type="checkbox" name="all_inbound_domains" value="1" {{if .FormAllInbound}}checked{{end}}>
All, including domains added later
</label>
{{range .InboundDomains}}
<label class="check">
<input type="checkbox" name="inbound_domain_ids" value="{{.ID}}" {{if index $.FormInbound .ID}}checked{{end}}>
{{.Name}}
</label>
{{end}}
</fieldset>
{{end}}
</div>
<button type="submit">{{if .IsEdit}}Save{{else}}Create{{end}}</button>
</form>
{{if .IsEdit}}
{{if .LastGlobalLocked}}
<p class="muted">The only global administrator cannot be deleted.</p>
{{else}}
<a class="danger" href="/server/users/{{.UserID}}/delete">Delete user</a>
{{end}}
{{end}}
</div>
{{end}}
+13 -26
View File
@@ -1,29 +1,16 @@
{{/* Users (mockup users.html): everyone who may sign in to the panel, with the
reach of each. The Inbound column is there only where the inbound feature
is on. */}}
{{define "content"}}
<h1>Users</h1>
{{template "page_head" .Head}}
{{if .Flash}}{{template "flash" .Flash}}{{end}}
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
<div class="card">
<p><a href="/server/users/new">Create user</a></p>
{{if .Users}}
<table>
<thead>
<tr><th>Username</th><th>Role</th><th>Outbound domains</th>{{if .InboundEnabled}}<th>Inbound domains</th>{{end}}<th></th></tr>
</thead>
<tbody>
{{range .Users}}
<tr>
<td>{{.User.Username}}</td>
<td>{{.User.Role}}</td>
<td class="muted">{{if or (eq .User.Role "global") .User.AllDomains}}All{{else}}{{range $i, $n := .DomainNames}}{{if $i}}, {{end}}{{$n}}{{end}}{{end}}</td>
{{if $.InboundEnabled}}<td class="muted">{{if or (eq .User.Role "global") .User.AllInboundDomains}}All{{else}}{{range $i, $n := .InboundDomainNames}}{{if $i}}, {{end}}{{$n}}{{end}}{{end}}</td>{{end}}
<td class="actions"><a href="/server/users/{{.User.ID}}">Edit</a></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">No panel users besides the global administrator yet.</p>
{{end}}
</div>
{{template "box_open" .List}}
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Username</th><th>E-mail</th><th>Role</th><th>Outbound domains</th>{{if .ShowInbound}}<th>Inbound domains</th>{{end}}<th></th></tr></thead>
<tbody>{{range .Rows}}
<tr><td><strong>{{.Name}}</strong>{{if .You}} {{template "tag" .YouTag}}{{end}}</td><td class="sp-muted">{{wbr_at .Email}}</td><td>{{template "tag" .Role}}</td><td class="sp-muted">{{.Outbound}}</td>{{if $.ShowInbound}}<td class="sp-muted">{{.Inbound}}</td>{{end}}<td class="sp-actions"><a href="{{.Href}}">Edit</a></td></tr>{{end}}
</tbody>
</table></div>
{{template "box_close"}}
{{end}}