panel: shared nav, account settings, backup page, connection settings
Phase 12 (UI/UX). The navigation bar now renders once from layout.html instead of being copied into each content template, so it is present on every authenticated page — including the domain page and its delete confirmation, which had no links at all — and the current page is highlighted via .Active rather than quietly dropping out of the list. New /account page changes the administrator's username and/or password: the current password is required and the attempt is throttled on the same limiter as the login form, so this route cannot be used to brute-force past that limit. A password change invalidates every other session while keeping the one performing it; a rename carries that session over. Backup and domain import move from a card in the middle of the domain list to their own /backup page, one card each; the handlers themselves are unchanged, only the page the import form renders its errors on. The domain page gains a "Sending server settings" card (server, port, encryption) so a client can be configured without reading the docs; 587 is listed only when SUBMISSION_ENABLE is true for this deployment, which is a deploy-time flag the panel cannot verify at runtime. Client-side (static/panel.js, no libraries): Copy buttons on the values that get carried elsewhere (DKIM record, new application credentials, server name), and the Addresses field is hidden while the address mode is wildcard, where the server ignores it. Verified in a container on the dev server: setup, login, every page's nav and active item, domain and application creation, all account-form paths including cross-session invalidation, import errors, full backup download. gofmt/vet/test/docker build green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
{{define "content"}}
|
||||
<h1>Account</h1>
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
|
||||
<div class="card narrow">
|
||||
<h2>Panel credentials</h2>
|
||||
<p class="muted">These are the credentials for this control panel only.
|
||||
Applications keep their own logins and passwords, which are not affected.</p>
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/account">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" autocomplete="username"
|
||||
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
|
||||
|
||||
<label for="current_password">Current password</label>
|
||||
<input id="current_password" name="current_password" type="password"
|
||||
autocomplete="current-password" required>
|
||||
|
||||
<label for="new_password">New password</label>
|
||||
<input id="new_password" name="new_password" type="password" autocomplete="new-password">
|
||||
|
||||
<label for="new_password_confirm">Confirm new password</label>
|
||||
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password">
|
||||
|
||||
<button type="submit">Save changes</button>
|
||||
</form>
|
||||
<p class="muted">Leave both new-password fields empty to change the username
|
||||
only. Changing the password signs out every other session; this one stays
|
||||
signed in.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,32 @@
|
||||
{{define "content"}}
|
||||
<h1>Backup & migration</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>Full backup</h2>
|
||||
<p class="muted">Download a full backup of all persistent state — the database,
|
||||
every domain's DKIM key and the application credentials. Use it to move the
|
||||
whole server to a new machine: restore it into a container of the
|
||||
<strong>same SelfPost version</strong>, with the same data mount, before first
|
||||
start. TLS certificates and the mail queue are not included.</p>
|
||||
<p class="muted"><strong>The backup file is a secret</strong> (it contains
|
||||
private keys and credentials). Store and transfer it securely and delete it
|
||||
once the restore succeeds.</p>
|
||||
<form class="inline" method="post" action="/backup">
|
||||
<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). 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="/domains/import" enctype="multipart/form-data">
|
||||
<label for="importfile">Domain export file</label>
|
||||
<input id="importfile" name="file" type="file" accept=".json,application/json" required>
|
||||
<button type="submit">Import domain</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -2,16 +2,9 @@
|
||||
<div class="topbar">
|
||||
<h1>SelfPost</h1>
|
||||
<div class="actions muted">
|
||||
<span>{{.User}}</span>
|
||||
<a href="/sendlog">Send log</a>
|
||||
<a href="/queue">Queue</a>
|
||||
<a href="/logtail">Log</a>
|
||||
<form class="inline" method="post" action="/reload">
|
||||
<button type="submit">Reload</button>
|
||||
</form>
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<button type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,31 +46,4 @@
|
||||
<p class="muted">No domains yet. Add one above to get started.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Backup & migration</h2>
|
||||
<p class="muted">Download a full backup of all persistent state — the database,
|
||||
every domain's DKIM key and the application credentials. Use it to move the
|
||||
whole server to a new machine: restore it into a container of the
|
||||
<strong>same SelfPost version</strong>, with the same data mount, before first
|
||||
start. TLS certificates and the mail queue are not included.</p>
|
||||
<p class="muted"><strong>The backup file is a secret</strong> (it contains
|
||||
private keys and credentials). Store and transfer it securely and delete it
|
||||
once the restore succeeds.</p>
|
||||
<form class="inline" method="post" action="/backup">
|
||||
<button type="submit">Download full backup</button>
|
||||
</form>
|
||||
|
||||
<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). 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="/domains/import" enctype="multipart/form-data">
|
||||
<label for="importfile">Domain export file</label>
|
||||
<input id="importfile" name="file" type="file" accept=".json,application/json" required>
|
||||
<button type="submit">Import domain</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
{{define "content"}}
|
||||
<div class="topbar">
|
||||
<h1>Delete {{.Domain.Name}}</h1>
|
||||
<div class="actions muted">
|
||||
<span>{{.User}}</span>
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<button type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<h1>Delete {{.Domain.Name}}</h1>
|
||||
|
||||
<a class="back" href="/domains/{{.Domain.ID}}">← Back to {{.Domain.Name}}</a>
|
||||
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
{{define "content"}}
|
||||
<div class="topbar">
|
||||
<h1>{{.Domain.Name}}</h1>
|
||||
<div class="actions muted">
|
||||
<span>{{.User}}</span>
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<button type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<h1>{{.Domain.Name}}</h1>
|
||||
|
||||
<a class="back" href="/">← All domains</a>
|
||||
|
||||
@@ -20,9 +12,15 @@
|
||||
<p class="muted">This password is shown <strong>once only</strong> and is not
|
||||
stored. Copy it now — if it is lost, regenerate a new one.</p>
|
||||
<label>Login</label>
|
||||
<span class="code">{{.NewCred.Login}}</span>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.NewCred.Login}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
<label>Password</label>
|
||||
<span class="code">{{.NewCred.Password}}</span>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.NewCred.Password}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -32,18 +30,47 @@
|
||||
It is not a secret and can be viewed at any time.</p>
|
||||
|
||||
<label>Host / name</label>
|
||||
<span class="code">{{.Record.Name}}</span>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.Record.Name}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
|
||||
<label>Value</label>
|
||||
<span class="code">{{.Record.Value}}</span>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.Record.Value}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<p class="muted">Also configure SPF and DMARC for the domain (see the
|
||||
documentation). Mail is signed with selector <strong>{{.Domain.DKIMSelector}}</strong>.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Sending server settings</h2>
|
||||
<p class="muted">Point the mail client or script at these settings and
|
||||
authenticate with an application login and password from the
|
||||
<strong>Applications</strong> section below. They are the same for every
|
||||
domain on this server.</p>
|
||||
|
||||
<label>Server</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.Hostname}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<label>Port and encryption</label>
|
||||
<span class="code">465 — SSL/TLS (implicit){{if .SubmissionEnabled}}
|
||||
587 — STARTTLS (submission){{end}}</span>
|
||||
|
||||
<p class="muted">Authentication is required on every port. The username is the
|
||||
application's login (see the table below) and the password is the one shown
|
||||
once when that application was created or its password regenerated — if it was
|
||||
lost, generate a new one.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Applications</h2>
|
||||
<p class="muted">Each application is a SASL login/password an app or script
|
||||
@@ -70,13 +97,15 @@
|
||||
<summary>Edit mode</summary>
|
||||
<form method="post" action="/applications/{{.ID}}/mode">
|
||||
<label>Address mode</label>
|
||||
<select name="mode">
|
||||
<select name="mode" data-list-mode="{{$.List}}">
|
||||
<option value="{{$.Wildcard}}" {{if eq .AddressMode $.Wildcard}}selected{{end}}>Any address of the domain</option>
|
||||
<option value="{{$.List}}" {{if eq .AddressMode $.List}}selected{{end}}>Specific addresses (list)</option>
|
||||
</select>
|
||||
<label>Addresses (for list mode; one per line or comma-separated)</label>
|
||||
<textarea name="addresses" rows="3" placeholder="alerts@{{$.Domain.Name}}">{{range $i, $a := .Addresses}}{{if $i}}
|
||||
<div data-addresses>
|
||||
<label>Addresses (one per line or comma-separated)</label>
|
||||
<textarea name="addresses" rows="3" placeholder="alerts@{{$.Domain.Name}}">{{range $i, $a := .Addresses}}{{if $i}}
|
||||
{{end}}{{$a}}{{end}}</textarea>
|
||||
</div>
|
||||
<button type="submit">Save mode</button>
|
||||
</form>
|
||||
</details>
|
||||
@@ -161,14 +190,16 @@
|
||||
value="{{.FormLogin}}" required>
|
||||
|
||||
<label for="mode">Address mode</label>
|
||||
<select id="mode" name="mode">
|
||||
<select id="mode" name="mode" data-list-mode="{{.List}}">
|
||||
<option value="{{.Wildcard}}" {{if eq .FormMode .Wildcard}}selected{{end}}>Any address of the domain</option>
|
||||
<option value="{{.List}}" {{if eq .FormMode .List}}selected{{end}}>Specific addresses (list)</option>
|
||||
</select>
|
||||
|
||||
<label for="addresses">Addresses (for list mode; one per line or comma-separated)</label>
|
||||
<textarea id="addresses" name="addresses" rows="3"
|
||||
placeholder="alerts@{{.Domain.Name}}">{{.FormAddrs}}</textarea>
|
||||
<div data-addresses>
|
||||
<label for="addresses">Addresses (one per line or comma-separated)</label>
|
||||
<textarea id="addresses" name="addresses" rows="3"
|
||||
placeholder="alerts@{{.Domain.Name}}">{{.FormAddrs}}</textarea>
|
||||
</div>
|
||||
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<button type="submit">Create application</button>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="icon" href="/static/favicon.png" type="image/png">
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
<script src="/static/panel.js" defer></script>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
* { box-sizing: border-box; }
|
||||
@@ -75,11 +76,63 @@
|
||||
details form { margin-top: 0.6rem; }
|
||||
.credential { border-color: #f5c518; background: #fffbeb; }
|
||||
@media (prefers-color-scheme: dark) { .credential { background: #2a2408 !important; border-color: #6b5a10 !important; } }
|
||||
/* Panel navigation: rendered once from this layout, so it is present on every
|
||||
authenticated page without each content template having to include it. */
|
||||
.nav {
|
||||
display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline;
|
||||
gap: 0.4rem 1rem; margin-bottom: 1.2rem; padding-bottom: 0.6rem;
|
||||
border-bottom: 1px solid #e2e5e9;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) { .nav { border-color: #2b3138 !important; } }
|
||||
.nav .links, .nav .session { display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem; align-items: baseline; }
|
||||
.nav .links a, .nav .links [aria-current] { padding: 0.2rem 0.5rem; border-radius: 6px; }
|
||||
.nav .links [aria-current] {
|
||||
font-weight: 600; color: #1b1f24; background: #e6ebf5; box-shadow: inset 0 -2px 0 #2563eb;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.nav .links [aria-current] { color: #e6e8eb !important; background: #22303f !important; }
|
||||
}
|
||||
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
|
||||
.code-row .code { flex: 1; min-width: 0; }
|
||||
button.copy {
|
||||
flex: none; margin-top: 0.3rem; padding: 0.45rem 0.7rem; font-size: 0.8rem;
|
||||
background: #eef1f5; color: #2563eb; border: 1px solid #cfd4da;
|
||||
}
|
||||
button.copy:hover { background: #e2e7ee; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
button.copy { background: #22262b !important; border-color: #2b3138 !important; }
|
||||
button.copy:hover { background: #2b3138 !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
{{if .User}}{{template "nav" .}}{{end}}
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
|
||||
{{/* nav is the panel's navigation bar. It is rendered here, from the layout, so
|
||||
every authenticated page has it without the page's own template having to
|
||||
remember to include it; .Active names the current page so it is highlighted
|
||||
instead of linking to itself. Unauthenticated pages (login, setup) carry no
|
||||
.User and get no nav. */}}
|
||||
{{define "nav"}}
|
||||
<nav class="nav">
|
||||
<div class="links">
|
||||
{{if eq .Active "domains"}}<span aria-current="page">Domains</span>{{else}}<a href="/">Domains</a>{{end}}
|
||||
{{if eq .Active "sendlog"}}<span aria-current="page">Send log</span>{{else}}<a href="/sendlog">Send log</a>{{end}}
|
||||
{{if eq .Active "queue"}}<span aria-current="page">Queue</span>{{else}}<a href="/queue">Queue</a>{{end}}
|
||||
{{if eq .Active "logtail"}}<span aria-current="page">Log</span>{{else}}<a href="/logtail">Log</a>{{end}}
|
||||
{{if eq .Active "backup"}}<span aria-current="page">Backup</span>{{else}}<a href="/backup">Backup</a>{{end}}
|
||||
</div>
|
||||
<div class="session">
|
||||
<span class="muted">{{.User}}</span>
|
||||
{{if eq .Active "account"}}<span aria-current="page">Account</span>{{else}}<a href="/account">Account</a>{{end}}
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<button type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
{{end}}
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
{{define "content"}}
|
||||
<div class="topbar">
|
||||
<h1>Mail log</h1>
|
||||
<div class="actions muted">
|
||||
<span>{{.User}}</span>
|
||||
<a href="/">Domains</a>
|
||||
<a href="/sendlog">Send log</a>
|
||||
<a href="/queue">Queue</a>
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<button type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="back" href="/">← Domains</a>
|
||||
<h1>Mail log</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>mail.log tail</h2>
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
{{define "content"}}
|
||||
<div class="topbar">
|
||||
<h1>Mail queue</h1>
|
||||
<div class="actions muted">
|
||||
<span>{{.User}}</span>
|
||||
<a href="/">Domains</a>
|
||||
<a href="/sendlog">Send log</a>
|
||||
<a href="/logtail">Log</a>
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<button type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="back" href="/">← Domains</a>
|
||||
<h1>Mail queue</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>postqueue -p</h2>
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
{{define "content"}}
|
||||
<div class="topbar">
|
||||
<h1>Send log</h1>
|
||||
<div class="actions muted">
|
||||
<span>{{.User}}</span>
|
||||
<a href="/">Domains</a>
|
||||
<a href="/queue">Queue</a>
|
||||
<a href="/logtail">Log</a>
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<button type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="back" href="/">← Domains</a>
|
||||
<h1>Send log</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>Filter</h2>
|
||||
|
||||
Reference in New Issue
Block a user