Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+9
-4
@@ -7,10 +7,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- panel: the domain detail page is wide with paired cards (DKIM ‖ DNS status;
|
- panel: the domain detail page is wide with paired cards (DKIM ‖ SPF+DMARC;
|
||||||
SPF ‖ DMARC; sending settings ‖ add application; rate limit ‖ export).
|
sending settings ‖ add application; export ‖ danger). DNS status,
|
||||||
Applications and Danger stay full-width; SPF and DMARC are separate cards;
|
Applications and Domain settings are full-width. DNS status is two rows
|
||||||
section blurbs are shorter.
|
(DKIM ‖ SPF, DMARC ‖ report authorization); Domain settings pairs DMARC
|
||||||
|
report mode with the domain rate limit; application Edit opens mode and
|
||||||
|
rate limit side by side; the custom rua address field is shown only for
|
||||||
|
Custom address. The in-nav “On this page” section index is removed (Status
|
||||||
|
already dropped it; the paired domain layout no longer needs it). Section
|
||||||
|
blurbs are shorter.
|
||||||
- panel: page URLs, browser titles, and headings are aligned — **Settings** is
|
- panel: page URLs, browser titles, and headings are aligned — **Settings** is
|
||||||
now `/settings` (legacy `/account` redirects with 308); the domains list title
|
now `/settings` (legacy `/account` redirects with 308); the domains list title
|
||||||
is `SelfPost — domains`; Status, Users, and user create/edit titles match their
|
is `SelfPost — domains`; Status, Users, and user create/edit titles match their
|
||||||
|
|||||||
@@ -259,9 +259,9 @@ th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid var(
|
|||||||
td.time, td.status { white-space: nowrap; }
|
td.time, td.status { white-space: nowrap; }
|
||||||
/* Column headings are set in the mono face: they are labels for machine data
|
/* Column headings are set in the mono face: they are labels for machine data
|
||||||
rather than prose, and the wider tracking a small monospaced capital wants
|
rather than prose, and the wider tracking a small monospaced capital wants
|
||||||
also holds them apart from the first row of values. Same treatment on the
|
also holds them apart from the first row of values. Same treatment on
|
||||||
other two small all-caps labels in the panel (.sections-title, .fact-label),
|
.fact-label, so a heading of that size reads as one thing wherever it
|
||||||
so a heading of that size reads as one thing wherever it appears. */
|
appears. */
|
||||||
th {
|
th {
|
||||||
font-family: var(--font-mono); font-size: 0.75rem; font-weight: 500;
|
font-family: var(--font-mono); font-size: 0.75rem; font-weight: 500;
|
||||||
text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted);
|
text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted);
|
||||||
@@ -329,23 +329,19 @@ button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); }
|
|||||||
.app .actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.7rem; }
|
.app .actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.7rem; }
|
||||||
/* Mode and rate limit open under the whole row rather than inside it. As
|
/* Mode and rate limit open under the whole row rather than inside it. As
|
||||||
<details> the panel sat where its summary was, so opening one cut the row of
|
<details> the panel sat where its summary was, so opening one cut the row of
|
||||||
four controls in half and pushed the rest below a block of fields — the
|
controls in half and pushed the rest below a block of fields — the buttons
|
||||||
buttons moved every time a panel opened or closed. The panels are therefore
|
moved every time a panel opened or closed. The panel is therefore the last
|
||||||
the last children of the row, and the checkbox that opens each one is
|
child of the row, and the checkbox that opens it is visually hidden earlier
|
||||||
visually hidden earlier in the row with its label drawn as the button. The
|
in the row with its label drawn as the button. The checkbox stays in the tab
|
||||||
checkbox stays in the tab order and keeps its focus ring on the label, so it
|
order and keeps its focus ring on the label, so it works from the keyboard,
|
||||||
works from the keyboard, and being pure CSS it also works with JavaScript
|
and being pure CSS it also works with JavaScript blocked, as <details> did. */
|
||||||
blocked, as <details> did. */
|
|
||||||
.app .actions > .panel-toggle {
|
.app .actions > .panel-toggle {
|
||||||
position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none;
|
position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none;
|
||||||
}
|
}
|
||||||
.app .actions > .panel { display: none; flex: 1 0 100%; }
|
.app .actions > .panel { display: none; flex: 1 0 100%; }
|
||||||
.app .actions > .t-mode:checked ~ .panel-mode,
|
.app .actions > .t-edit:checked ~ .panel-edit { display: block; }
|
||||||
.app .actions > .t-limit:checked ~ .panel-limit { display: block; }
|
.app .actions > .t-edit:checked ~ .for-edit { background: var(--surface-open-bg); }
|
||||||
.app .actions > .t-mode:checked ~ .for-mode,
|
.app .actions > .t-edit:focus-visible ~ .for-edit { outline: 2px solid var(--accent-fill); outline-offset: 2px; }
|
||||||
.app .actions > .t-limit:checked ~ .for-limit { background: var(--surface-open-bg); }
|
|
||||||
.app .actions > .t-mode:focus-visible ~ .for-mode,
|
|
||||||
.app .actions > .t-limit:focus-visible ~ .for-limit { outline: 2px solid var(--accent-fill); outline-offset: 2px; }
|
|
||||||
.panel form { margin-top: 0.6rem; }
|
.panel form { margin-top: 0.6rem; }
|
||||||
/* A panel's own submit is a form button, not one of the controls in the row
|
/* A panel's own submit is a form button, not one of the controls in the row
|
||||||
above, so it takes back the spacing the compact .actions rule zeroes out:
|
above, so it takes back the spacing the compact .actions rule zeroes out:
|
||||||
@@ -362,6 +358,8 @@ button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); }
|
|||||||
}
|
}
|
||||||
.app .actions > .panel .panel-buttons button,
|
.app .actions > .panel .panel-buttons button,
|
||||||
.app .actions > .panel .panel-buttons form { margin-top: 0; }
|
.app .actions > .panel .panel-buttons form { margin-top: 0; }
|
||||||
|
.app .actions > .panel .check-cols { margin-top: 0.6rem; }
|
||||||
|
.app .actions > .panel .check-col > form { margin-top: 0; }
|
||||||
.credential { border-color: var(--credential-border); background: var(--credential-bg); }
|
.credential { border-color: var(--credential-border); background: var(--credential-bg); }
|
||||||
/* Panel navigation: rendered once from the layout, so it is present on every
|
/* Panel navigation: rendered once from the layout, so it is present on every
|
||||||
authenticated page without each content template having to include it. */
|
authenticated page without each content template having to include it. */
|
||||||
@@ -370,11 +368,9 @@ button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); }
|
|||||||
738px the panel was wide, so as a bar it had to be split over two rows — and
|
738px the panel was wide, so as a bar it had to be split over two rows — and
|
||||||
even then it cost the top of every page. Standing it up removes that: the
|
even then it cost the top of every page. Standing it up removes that: the
|
||||||
entries share one left edge to scan down, the session sits at the foot where
|
entries share one left edge to scan down, the session sits at the foot where
|
||||||
it is out of the way, and there is room between them for the current page's
|
it is out of the way. Sticky, so the list stays in view while the page
|
||||||
own sections (.sections below), which is what makes the long pages navigable.
|
scrolls past it. The layout template lists the blocks in the order they are
|
||||||
Sticky, so both lists stay in view while the page scrolls past them. The
|
drawn, so reading and tab order follow the eye without a CSS `order`. */
|
||||||
layout template lists the blocks in the order they are drawn, so reading and
|
|
||||||
tab order follow the eye without a CSS `order`. */
|
|
||||||
.nav {
|
.nav {
|
||||||
position: sticky; top: 2rem; align-self: flex-start;
|
position: sticky; top: 2rem; align-self: flex-start;
|
||||||
flex: none; width: 13.5rem;
|
flex: none; width: 13.5rem;
|
||||||
@@ -439,32 +435,7 @@ button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); }
|
|||||||
/* The icons draw in the entry's own colour, so the active entry's darker text,
|
/* The icons draw in the entry's own colour, so the active entry's darker text,
|
||||||
a link's blue and Sign out's red all carry through without a rule apiece. */
|
a link's blue and Sign out's red all carry through without a rule apiece. */
|
||||||
.nav .icon { width: 1rem; height: 1rem; flex: none; }
|
.nav .icon { width: 1rem; height: 1rem; flex: none; }
|
||||||
/* The current page's own sections, listed under the page entries. The domain
|
/* Jumping to a card (in-page links) should not leave it touching the top edge. */
|
||||||
page is long enough that the only way to the card you came for was to scroll
|
|
||||||
past all the ones you did not. Only pages that long carry an index — it comes
|
|
||||||
from the page's own "sections" template (see layout.html), so a page with
|
|
||||||
two cards or a short paired Status layout renders nothing here. */
|
|
||||||
.sections {
|
|
||||||
display: flex; flex-direction: column; gap: 0.1rem;
|
|
||||||
padding-top: 0.75rem; border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.sections-title {
|
|
||||||
margin: 0 0 0.25rem 0.6rem;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.7rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.1em;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
/* Quieter and a step in from the page entries: this is an index of one page,
|
|
||||||
subordinate to the list of pages above it. */
|
|
||||||
.nav .sections a {
|
|
||||||
padding: 0.2rem 0.6rem 0.2rem 0.9rem;
|
|
||||||
font-size: 0.85rem; color: var(--muted); text-decoration: none;
|
|
||||||
}
|
|
||||||
.nav .sections a:hover { color: var(--fg); }
|
|
||||||
/* panel.js marks the section the page is scrolled to. Without JavaScript
|
|
||||||
nothing is marked and the list is still a working index. */
|
|
||||||
.nav .sections a.current { color: var(--fg); font-weight: 600; background: var(--nav-active-bg); }
|
|
||||||
/* Jumping to a card should not leave it touching the top edge of the window. */
|
|
||||||
.card[id] { scroll-margin-top: 1rem; }
|
.card[id] { scroll-margin-top: 1rem; }
|
||||||
/* Below the width the two columns need (13.5rem of navigation, 1.75rem of gap
|
/* Below the width the two columns need (13.5rem of navigation, 1.75rem of gap
|
||||||
and the 48rem measure, plus the body's padding — the measure, not the column
|
and the 48rem measure, plus the body's padding — the measure, not the column
|
||||||
@@ -488,11 +459,10 @@ button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); }
|
|||||||
.nav .brand img { width: 110px; }
|
.nav .brand img { width: 110px; }
|
||||||
/* Each block keeps its own group of entries together and wraps as one; the
|
/* Each block keeps its own group of entries together and wraps as one; the
|
||||||
rules that separated the blocks vertically become the space between them. */
|
rules that separated the blocks vertically become the space between them. */
|
||||||
.nav .links, .nav .session, .nav .sections {
|
.nav .links, .nav .session {
|
||||||
flex-direction: row; flex-wrap: wrap; align-items: center;
|
flex-direction: row; flex-wrap: wrap; align-items: center;
|
||||||
gap: 0.2rem 0.6rem; padding-top: 0; border-top: 0;
|
gap: 0.2rem 0.6rem; padding-top: 0; border-top: 0;
|
||||||
}
|
}
|
||||||
.sections-title { margin: 0; }
|
|
||||||
}
|
}
|
||||||
/* Status badges: one vocabulary (ok/warn/error/unknown) shared by the server
|
/* Status badges: one vocabulary (ok/warn/error/unknown) shared by the server
|
||||||
status page and the per-domain DNS checks, so a colour means the same thing
|
status page and the per-domain DNS checks, so a colour means the same thing
|
||||||
@@ -562,9 +532,31 @@ meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4re
|
|||||||
grid keep their spacing from it instead. */
|
grid keep their spacing from it instead. */
|
||||||
.split > .card + .card { margin-top: 0; }
|
.split > .card + .card { margin-top: 0; }
|
||||||
.split + .card { margin-top: 1.2rem; }
|
.split + .card { margin-top: 1.2rem; }
|
||||||
|
.card + .split { margin-top: 1.2rem; }
|
||||||
/* Configuration sits outside the polled fragment; without this it would
|
/* Configuration sits outside the polled fragment; without this it would
|
||||||
touch the last .split inside #status-body (a nested .split is not a sibling). */
|
touch the last .split inside #status-body (a nested .split is not a sibling). */
|
||||||
#status-body + .card { margin-top: 1.2rem; }
|
#status-body + .card { margin-top: 1.2rem; }
|
||||||
|
/* Peer checks or paired forms inside one card (domain DNS status: two rows of
|
||||||
|
two; domain settings; application edit). Same auto-fit idea as .split, so a
|
||||||
|
wide card gets two columns — not three — and falls to one when narrow. */
|
||||||
|
.check-cols {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
|
||||||
|
gap: 1rem 1.2rem; margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.check-col { min-width: 0; }
|
||||||
|
.check-col > label:first-child { margin-top: 0; }
|
||||||
|
.check-col-title {
|
||||||
|
margin: 1rem 0 0.35rem; font-size: 1.05rem; font-weight: 600;
|
||||||
|
}
|
||||||
|
.card > h2 + .check-col-title { margin-top: 0.55rem; }
|
||||||
|
/* Two short fields on one row (rate-limit message count ‖ window). Falls to
|
||||||
|
one column when the parent is too narrow for both. */
|
||||||
|
.field-pair {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
|
||||||
|
gap: 0 1rem; margin-top: 0.45rem;
|
||||||
|
}
|
||||||
|
.field-pair > div { min-width: 0; }
|
||||||
|
.field-pair label { margin-top: 0.45rem; }
|
||||||
/* The subject heads a delivery's page, and it is the one heading in the panel
|
/* The subject heads a delivery's page, and it is the one heading in the panel
|
||||||
whose text we do not control: it may be a hundred characters with nothing to
|
whose text we do not control: it may be a hundred characters with nothing to
|
||||||
break on. It wraps to as many lines as it needs (this is the page's name, not
|
break on. It wraps to as many lines as it needs (this is the page's name, not
|
||||||
|
|||||||
@@ -72,6 +72,28 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Custom DMARC rua address shown only for "custom" mode ------------
|
||||||
|
// Same idea as the address list: the email field only applies when the
|
||||||
|
// operator picks Custom address. With JavaScript blocked the field stays
|
||||||
|
// visible and the server still ignores it for inherit/none.
|
||||||
|
function syncCustomAddressField(select) {
|
||||||
|
var form = select.closest("form");
|
||||||
|
var field = form && form.querySelector("[data-custom-address]");
|
||||||
|
if (!field) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
field.hidden = select.value !== select.dataset.customMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initCustomAddressFields(root) {
|
||||||
|
root.querySelectorAll("select[data-custom-mode]").forEach(function (select) {
|
||||||
|
syncCustomAddressField(select);
|
||||||
|
select.addEventListener("change", function () {
|
||||||
|
syncCustomAddressField(select);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Domain pick shown only for domain administrators ------------------
|
// --- Domain pick shown only for domain administrators ------------------
|
||||||
// Global administrators manage every domain, so the assignment checkboxes
|
// Global administrators manage every domain, so the assignment checkboxes
|
||||||
// are irrelevant for that role. The toggle runs on load too, because the
|
// are irrelevant for that role. The toggle runs on load too, because the
|
||||||
@@ -157,74 +179,12 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Section index follows the page -----------------------------------
|
|
||||||
// The long pages list their own sections in the navigation column (the
|
|
||||||
// "sections" template). Marking the one currently in view turns that list
|
|
||||||
// from an index into a position, which is the whole point of it on a page
|
|
||||||
// nine cards tall. The links work without any of this; only the highlight
|
|
||||||
// depends on it.
|
|
||||||
//
|
|
||||||
// Each pass looks its targets up by id rather than holding on to elements
|
|
||||||
// found once: the status page replaces its cards wholesale every five
|
|
||||||
// seconds (adaptive polling on #status-body), and anything remembered here would be
|
|
||||||
// measuring boxes that had left the document.
|
|
||||||
var sectionLinks = [];
|
|
||||||
|
|
||||||
function markCurrentSection() {
|
|
||||||
var current = null;
|
|
||||||
sectionLinks.forEach(function (link) {
|
|
||||||
var target = document.getElementById(link.hash.slice(1));
|
|
||||||
// The section in view is the last one whose top has passed the reading
|
|
||||||
// line; the links are in document order, so the last match wins.
|
|
||||||
if (target && target.getBoundingClientRect().top <= 100) {
|
|
||||||
current = link;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2) {
|
|
||||||
// At the foot of the page there is no scroll left to bring the last
|
|
||||||
// cards up to the reading line, so without this they could never be
|
|
||||||
// marked however far down you are — and the last card of the domain
|
|
||||||
// page is the one that deletes it.
|
|
||||||
current = sectionLinks[sectionLinks.length - 1];
|
|
||||||
} else if (!current) {
|
|
||||||
// Above the first heading nothing has been passed yet, and the page is
|
|
||||||
// still on its first section.
|
|
||||||
current = sectionLinks[0];
|
|
||||||
}
|
|
||||||
sectionLinks.forEach(function (link) {
|
|
||||||
link.classList.toggle("current", link === current);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function initSectionIndex() {
|
|
||||||
sectionLinks = Array.prototype.slice.call(
|
|
||||||
document.querySelectorAll(".sections a[href^='#']")
|
|
||||||
);
|
|
||||||
if (!sectionLinks.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var pending = false;
|
|
||||||
// Scroll fires far more often than the highlight can change, so the work
|
|
||||||
// is collapsed onto the next frame.
|
|
||||||
window.addEventListener("scroll", function () {
|
|
||||||
if (pending) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pending = true;
|
|
||||||
window.requestAnimationFrame(function () {
|
|
||||||
pending = false;
|
|
||||||
markCurrentSection();
|
|
||||||
});
|
|
||||||
}, { passive: true });
|
|
||||||
markCurrentSection();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
initAddressFields(document);
|
initAddressFields(document);
|
||||||
|
initCustomAddressFields(document);
|
||||||
initDomainPickFields(document);
|
initDomainPickFields(document);
|
||||||
initEncryptFields(document);
|
initEncryptFields(document);
|
||||||
initImportPasswordField(document);
|
initImportPasswordField(document);
|
||||||
initSectionIndex();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Adaptive monitoring polling ---------------------------------------
|
// --- Adaptive monitoring polling ---------------------------------------
|
||||||
|
|||||||
@@ -28,11 +28,63 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{/* Two rows of two checks (.check-cols). */}}
|
||||||
|
<div class="card" id="dns-status">
|
||||||
|
<h2>DNS status <span class="st st-{{.DNS.Overall}}">{{.DNS.Overall}}</span></h2>
|
||||||
|
<p class="muted">Cached a few minutes — use <em>Re-check</em> after
|
||||||
|
publishing.</p>
|
||||||
|
|
||||||
|
<div class="check-cols">
|
||||||
|
<div class="check-col">
|
||||||
|
<label>DKIM <span class="st st-{{.DNS.DKIM.Status}}">{{.DNS.DKIM.Status}}</span></label>
|
||||||
|
<p class="muted">{{.Record.Name}}</p>
|
||||||
|
{{if ne .DNS.DKIM.Status "ok"}}
|
||||||
|
<p class="{{if eq .DNS.DKIM.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DKIM.Detail}}</p>
|
||||||
|
{{end}}
|
||||||
|
{{if .DNS.DKIM.Records}}<span class="code">{{range .DNS.DKIM.Records}}{{.}}
|
||||||
|
{{end}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="check-col">
|
||||||
|
<label>SPF <span class="st st-{{.DNS.SPF.Status}}">{{.DNS.SPF.Status}}</span></label>
|
||||||
|
<p class="muted">{{.Domain.Name}}</p>
|
||||||
|
{{if ne .DNS.SPF.Status "ok"}}
|
||||||
|
<p class="{{if eq .DNS.SPF.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.SPF.Detail}}</p>
|
||||||
|
{{end}}
|
||||||
|
<p class="muted">Shallow check: literal address only, no <code>include:</code> /
|
||||||
|
<code>redirect=</code>.</p>
|
||||||
|
{{if .DNS.SPF.Records}}<span class="code">{{range .DNS.SPF.Records}}{{.}}
|
||||||
|
{{end}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="check-col">
|
||||||
|
<label>DMARC <span class="st st-{{.DNS.DMARC.Status}}">{{.DNS.DMARC.Status}}</span></label>
|
||||||
|
<p class="{{if eq .DNS.DMARC.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARC.Detail}}</p>
|
||||||
|
{{if .DNS.DMARC.Records}}<span class="code">{{range .DNS.DMARC.Records}}{{.}}
|
||||||
|
{{end}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="check-col">
|
||||||
|
{{if .DNS.DMARCReportAuth.Status}}
|
||||||
|
<label>Report authorization <span class="st st-{{.DNS.DMARCReportAuth.Status}}">{{.DNS.DMARCReportAuth.Status}}</span></label>
|
||||||
|
<p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p>
|
||||||
|
{{if .DNS.DMARCReportAuth.Records}}<span class="code">{{range .DNS.DMARCReportAuth.Records}}{{.}}
|
||||||
|
{{end}}</span>{{end}}
|
||||||
|
{{else}}
|
||||||
|
<label>Report authorization</label>
|
||||||
|
<p class="muted">Not required (no external <code>rua=</code>).</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/dns-recheck">
|
||||||
|
<button type="submit">Re-check</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="split">
|
<div class="split">
|
||||||
<div class="card" id="dkim">
|
<div class="card" id="dkim">
|
||||||
<h2>DKIM DNS record</h2>
|
<h2>DKIM record</h2>
|
||||||
<p class="muted">Publish this TXT for <strong>{{.Domain.Name}}</strong>.
|
|
||||||
It is not a secret.</p>
|
|
||||||
|
|
||||||
<label>Host / name</label>
|
<label>Host / name</label>
|
||||||
<div class="code-row">
|
<div class="code-row">
|
||||||
@@ -49,91 +101,42 @@
|
|||||||
<button type="button" class="copy">Copy</button>
|
<button type="button" class="copy">Copy</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="muted">Signed with selector <strong>{{.Domain.DKIMSelector}}</strong>.</p>
|
<p class="muted">Not a secret. Signed with selector
|
||||||
|
<strong>{{.Domain.DKIMSelector}}</strong>.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" id="dns-status">
|
<div class="card" id="spf-dmarc">
|
||||||
<h2>DNS status <span class="st st-{{.DNS.Overall}}">{{.DNS.Overall}}</span></h2>
|
<h2>SPF and DMARC records</h2>
|
||||||
<p class="muted">Cached a few minutes — use <em>Re-check</em> after
|
|
||||||
publishing.</p>
|
|
||||||
|
|
||||||
<label>DKIM <span class="st st-{{.DNS.DKIM.Status}}">{{.DNS.DKIM.Status}}</span></label>
|
|
||||||
<p class="{{if eq .DNS.DKIM.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DKIM.Detail}}</p>
|
|
||||||
{{if .DNS.DKIM.Records}}<span class="code">{{range .DNS.DKIM.Records}}{{.}}
|
|
||||||
{{end}}</span>{{end}}
|
|
||||||
|
|
||||||
<label>SPF <span class="st st-{{.DNS.SPF.Status}}">{{.DNS.SPF.Status}}</span></label>
|
|
||||||
<p class="{{if eq .DNS.SPF.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.SPF.Detail}}</p>
|
|
||||||
{{if .DNS.SPF.Records}}<span class="code">{{range .DNS.SPF.Records}}{{.}}
|
|
||||||
{{end}}</span>{{end}}
|
|
||||||
<p class="muted">Shallow check: literal address only, no
|
|
||||||
<code>include:</code> / <code>redirect=</code>.</p>
|
|
||||||
|
|
||||||
<label>DMARC <span class="st st-{{.DNS.DMARC.Status}}">{{.DNS.DMARC.Status}}</span></label>
|
|
||||||
<p class="{{if eq .DNS.DMARC.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARC.Detail}}</p>
|
|
||||||
{{if .DNS.DMARC.Records}}<span class="code">{{range .DNS.DMARC.Records}}{{.}}
|
|
||||||
{{end}}</span>{{end}}
|
|
||||||
|
|
||||||
{{if .DNS.DMARCReportAuth.Status}}
|
|
||||||
<label>Report authorization <span class="st st-{{.DNS.DMARCReportAuth.Status}}">{{.DNS.DMARCReportAuth.Status}}</span></label>
|
|
||||||
<p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p>
|
|
||||||
{{if .DNS.DMARCReportAuth.Records}}<span class="code">{{range .DNS.DMARCReportAuth.Records}}{{.}}
|
|
||||||
{{end}}</span>{{end}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/dns-recheck">
|
|
||||||
<button type="submit">Re-check</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="split">
|
|
||||||
<div class="card" id="spf">
|
|
||||||
<h2>SPF record</h2>
|
|
||||||
<p class="muted">Suggested TXT. If the domain already has SPF, merge this
|
|
||||||
server's mechanism into it — do not add a second record.</p>
|
|
||||||
|
|
||||||
|
<p class="check-col-title">SPF</p>
|
||||||
<label>Host / name</label>
|
<label>Host / name</label>
|
||||||
<div class="code-row">
|
<div class="code-row">
|
||||||
<span class="code">{{.Domain.Name}}</span>
|
<span class="code">{{.Domain.Name}}</span>
|
||||||
<button type="button" class="copy">Copy</button>
|
<button type="button" class="copy">Copy</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
|
||||||
<label>Value</label>
|
<label>Value</label>
|
||||||
<div class="code-row">
|
<div class="code-row">
|
||||||
<span class="code">{{.SPFExample}}</span>
|
<span class="code">{{.SPFExample}}</span>
|
||||||
<button type="button" class="copy">Copy</button>
|
<button type="button" class="copy">Copy</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" id="dmarc">
|
<p class="muted">Merge into an existing SPF if the domain already has one —
|
||||||
<h2>DMARC record</h2>
|
do not publish a second record.</p>
|
||||||
<p class="muted">Policy TXT. SelfPost is send-only — omit <code>rua=</code>
|
|
||||||
or point it at a mailbox elsewhere (<a href="/settings">Settings</a> default
|
|
||||||
or custom below).</p>
|
|
||||||
|
|
||||||
<form method="post" action="/domains/{{.Domain.ID}}/dmarc">
|
|
||||||
<label for="dmarc_rua_mode">Aggregate reports (rua=)</label>
|
|
||||||
<select id="dmarc_rua_mode" name="dmarc_rua_mode">
|
|
||||||
<option value="inherit"{{if eq .DMARCRuaMode "inherit"}} selected{{end}}>Same as Settings{{if .ProfileDMARCEmail}} ({{.ProfileDMARCEmail}}){{end}}</option>
|
|
||||||
<option value="none"{{if eq .DMARCRuaMode "none"}} selected{{end}}>No aggregate reports</option>
|
|
||||||
<option value="custom"{{if eq .DMARCRuaMode "custom"}} selected{{end}}>Custom address</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label for="dmarc_rua_email">Custom report address</label>
|
|
||||||
<input id="dmarc_rua_email" name="dmarc_rua_email" type="email"
|
|
||||||
autocapitalize="none" spellcheck="false" value="{{.DMARCRuaCustom}}"
|
|
||||||
placeholder="reports@your-mail-domain.com">
|
|
||||||
|
|
||||||
<button type="submit">Save DMARC report settings</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
|
<p class="check-col-title">DMARC</p>
|
||||||
<label>Host / name</label>
|
<label>Host / name</label>
|
||||||
<div class="code-row">
|
<div class="code-row">
|
||||||
<span class="code">{{.DMARCName}}</span>
|
<span class="code">{{.DMARCName}}</span>
|
||||||
<button type="button" class="copy">Copy</button>
|
<button type="button" class="copy">Copy</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
|
||||||
<label>Value{{if eq .DMARCSource "settings"}} <span class="muted">(from Settings)</span>{{else if eq .DMARCSource "custom"}} <span class="muted">(custom)</span>{{else if eq .DMARCSource "none"}} <span class="muted">(no reports)</span>{{end}}</label>
|
<label>Value{{if eq .DMARCSource "settings"}} <span class="muted">(from Settings)</span>{{else if eq .DMARCSource "custom"}} <span class="muted">(custom)</span>{{else if eq .DMARCSource "none"}} <span class="muted">(no reports)</span>{{end}}</label>
|
||||||
<div class="code-row">
|
<div class="code-row">
|
||||||
<span class="code">{{.DMARCExample}}</span>
|
<span class="code">{{.DMARCExample}}</span>
|
||||||
@@ -147,13 +150,17 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .NeedsReportAuth}}
|
{{if .NeedsReportAuth}}
|
||||||
<label>Report authorization — host / name</label>
|
<p class="check-col-title">Report authorization</p>
|
||||||
|
<label>Host / name</label>
|
||||||
<div class="code-row">
|
<div class="code-row">
|
||||||
<span class="code">{{.ReportAuthName}}</span>
|
<span class="code">{{.ReportAuthName}}</span>
|
||||||
<button type="button" class="copy">Copy</button>
|
<button type="button" class="copy">Copy</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label>Report authorization — value</label>
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
|
||||||
|
<label>Value</label>
|
||||||
<div class="code-row">
|
<div class="code-row">
|
||||||
<span class="code">{{.ReportAuthValue}}</span>
|
<span class="code">{{.ReportAuthValue}}</span>
|
||||||
<button type="button" class="copy">Copy</button>
|
<button type="button" class="copy">Copy</button>
|
||||||
@@ -161,7 +168,8 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<p class="muted"><code>p=none</code> does not affect delivery. Tighten to
|
<p class="muted"><code>p=none</code> does not affect delivery. Tighten to
|
||||||
<code>p=quarantine</code> then <code>p=reject</code> once reports look clean.</p>
|
<code>p=quarantine</code> then <code>p=reject</code> once reports look clean.
|
||||||
|
Report address is set under <a href="#domain-settings">Domain settings</a>.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -229,17 +237,12 @@
|
|||||||
{{if eq .AddressMode $.Wildcard}}Any address of the domain — *@{{$.Domain.Name}}
|
{{if eq .AddressMode $.Wildcard}}Any address of the domain — *@{{$.Domain.Name}}
|
||||||
{{else}}Fixed list — {{range $i, $a := .Addresses}}{{if $i}}, {{end}}{{$a}}{{end}}{{end}}
|
{{else}}Fixed list — {{range $i, $a := .Addresses}}{{if $i}}, {{end}}{{$a}}{{end}}{{end}}
|
||||||
</p>
|
</p>
|
||||||
<!-- The two panels are opened by a checkbox and a label rather than by
|
<!-- One Edit panel (mode ‖ rate limit) opened by a checkbox and label
|
||||||
<details>, so that the controls stay a single row of buttons and
|
rather than <details>, so the button row stays intact — see
|
||||||
what a panel reveals is laid out under all four of them instead of
|
.panel-toggle in panel.css. -->
|
||||||
splitting the row in two. The checkbox precedes both its label and
|
|
||||||
its panel, which is what lets the CSS alone show them (no script
|
|
||||||
involved) — see .panel-toggle in panel.css. -->
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<input class="panel-toggle t-mode" id="mode-{{.ID}}" type="checkbox">
|
<input class="panel-toggle t-edit" id="edit-{{.ID}}" type="checkbox">
|
||||||
<label class="toggle for-mode" for="mode-{{.ID}}">Edit mode</label>
|
<label class="toggle for-edit" for="edit-{{.ID}}">Edit{{if .HasLimit}} (limit active){{end}}</label>
|
||||||
<input class="panel-toggle t-limit" id="limit-{{.ID}}" type="checkbox">
|
|
||||||
<label class="toggle for-limit" for="limit-{{.ID}}">Rate limit{{if .HasLimit}} (active){{end}}</label>
|
|
||||||
<form class="inline" method="post" action="/applications/{{.ID}}/password"
|
<form class="inline" method="post" action="/applications/{{.ID}}/password"
|
||||||
data-confirm="Regenerate the password for {{.Login}}? The current password stops working immediately.">
|
data-confirm="Regenerate the password for {{.Login}}? The current password stops working immediately.">
|
||||||
<button type="submit">New password</button>
|
<button type="submit">New password</button>
|
||||||
@@ -248,7 +251,10 @@
|
|||||||
data-confirm="Delete application {{.Login}}? Its credentials stop working immediately.">
|
data-confirm="Delete application {{.Login}}? Its credentials stop working immediately.">
|
||||||
<button type="submit" class="danger">Delete</button>
|
<button type="submit" class="danger">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="panel panel-mode">
|
<div class="panel panel-edit">
|
||||||
|
<div class="check-cols">
|
||||||
|
<div class="check-col">
|
||||||
|
<p class="check-col-title">Address mode</p>
|
||||||
<form method="post" action="/applications/{{.ID}}/mode">
|
<form method="post" action="/applications/{{.ID}}/mode">
|
||||||
<label>Address mode</label>
|
<label>Address mode</label>
|
||||||
<select name="mode" data-list-mode="{{$.List}}">
|
<select name="mode" data-list-mode="{{$.List}}">
|
||||||
@@ -263,20 +269,26 @@
|
|||||||
<button type="submit">Save mode</button>
|
<button type="submit">Save mode</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel panel-limit">
|
<div class="check-col">
|
||||||
|
<p class="check-col-title">Rate limit</p>
|
||||||
<form id="rl-{{.ID}}" method="post" action="/applications/{{.ID}}/ratelimit">
|
<form id="rl-{{.ID}}" method="post" action="/applications/{{.ID}}/ratelimit">
|
||||||
<label>Expected client IPs (one per line or comma-separated)</label>
|
<label>Expected client IPs (one per line or comma-separated)</label>
|
||||||
<textarea name="allowed_ips" rows="2" placeholder="203.0.113.10">{{.IPsText}}</textarea>
|
<textarea name="allowed_ips" rows="2" placeholder="203.0.113.10">{{.IPsText}}</textarea>
|
||||||
|
<p class="muted">The limit counts only connections from these
|
||||||
|
IPs. Leave empty to leave the limit inactive.</p>
|
||||||
|
<div class="field-pair">
|
||||||
|
<div>
|
||||||
<label>Message limit</label>
|
<label>Message limit</label>
|
||||||
<input name="max_messages" type="number" min="1" value="{{.MaxText}}" placeholder="500">
|
<input name="max_messages" type="number" min="1" value="{{.MaxText}}" placeholder="500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
<label>Window (seconds)</label>
|
<label>Window (seconds)</label>
|
||||||
<input name="window_seconds" type="number" min="1" value="{{.WindowVal}}">
|
<input name="window_seconds" type="number" min="1" value="{{.WindowVal}}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<!-- Saving and removing the limit are two posts, so they are two
|
<!-- Saving and removing the limit are two posts; the Save
|
||||||
forms and could not share a line while the Save button sat
|
button is bound by form= id so both buttons share a row. -->
|
||||||
inside the block form above. It is bound to that form by id
|
|
||||||
instead (the form attribute, no script involved), which lets
|
|
||||||
both buttons stand together in one row under the fields. -->
|
|
||||||
<div class="panel-buttons">
|
<div class="panel-buttons">
|
||||||
<button type="submit" form="rl-{{.ID}}">Save limit</button>
|
<button type="submit" form="rl-{{.ID}}">Save limit</button>
|
||||||
{{if .HasLimit}}
|
{{if .HasLimit}}
|
||||||
@@ -289,6 +301,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -297,26 +311,56 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="split">
|
<div class="card" id="domain-settings">
|
||||||
<div class="card" id="rate-limit">
|
<h2>Domain settings</h2>
|
||||||
<h2>Sending rate limit (domain)</h2>
|
|
||||||
<p class="muted">Optional level-2 cap across this domain's applications from
|
|
||||||
the listed client IPs. Empty IP list = inactive (level-1 only).</p>
|
|
||||||
|
|
||||||
<p class="muted">Status:
|
<div class="check-cols">
|
||||||
{{if .DomainHasRL}}<strong>active</strong>{{else}}inactive (level-1 only){{end}}.</p>
|
<div class="check-col">
|
||||||
|
<p class="check-col-title">DMARC reports</p>
|
||||||
|
<p class="muted">Default comes from <a href="/settings">Settings</a>;
|
||||||
|
override per domain here.</p>
|
||||||
|
<form method="post" action="/domains/{{.Domain.ID}}/dmarc">
|
||||||
|
<label for="dmarc_rua_mode">Aggregate reports (rua=)</label>
|
||||||
|
<select id="dmarc_rua_mode" name="dmarc_rua_mode" data-custom-mode="custom">
|
||||||
|
<option value="inherit"{{if eq .DMARCRuaMode "inherit"}} selected{{end}}>Same as Settings{{if .ProfileDMARCEmail}} ({{.ProfileDMARCEmail}}){{end}}</option>
|
||||||
|
<option value="none"{{if eq .DMARCRuaMode "none"}} selected{{end}}>No aggregate reports</option>
|
||||||
|
<option value="custom"{{if eq .DMARCRuaMode "custom"}} selected{{end}}>Custom address</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div data-custom-address>
|
||||||
|
<label for="dmarc_rua_email">Custom report address</label>
|
||||||
|
<input id="dmarc_rua_email" name="dmarc_rua_email" type="email"
|
||||||
|
autocapitalize="none" spellcheck="false" value="{{.DMARCRuaCustom}}"
|
||||||
|
placeholder="reports@your-mail-domain.com">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit">Save DMARC report settings</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="check-col">
|
||||||
|
<p class="check-col-title">Optional level-2 sending rate limit</p>
|
||||||
|
<p class="muted">Empty IP list = inactive (level-1 only). Status:
|
||||||
|
{{if .DomainHasRL}}<strong>active</strong>{{else}}inactive{{end}}.</p>
|
||||||
|
|
||||||
<form method="post" action="/domains/{{.Domain.ID}}/ratelimit">
|
<form method="post" action="/domains/{{.Domain.ID}}/ratelimit">
|
||||||
<label for="d_ips">Expected client IPs (one per line or comma-separated)</label>
|
<label for="d_ips">Expected client IPs (one per line or comma-separated)</label>
|
||||||
<textarea id="d_ips" name="allowed_ips" rows="2"
|
<textarea id="d_ips" name="allowed_ips" rows="2"
|
||||||
placeholder="203.0.113.10">{{.DomainRLIPs}}</textarea>
|
placeholder="203.0.113.10">{{.DomainRLIPs}}</textarea>
|
||||||
|
<p class="muted">The limit counts only connections from these IPs.
|
||||||
|
Leave empty to leave the limit inactive.</p>
|
||||||
|
|
||||||
|
<div class="field-pair">
|
||||||
|
<div>
|
||||||
<label for="d_max">Message limit</label>
|
<label for="d_max">Message limit</label>
|
||||||
<input id="d_max" name="max_messages" type="number" min="1"
|
<input id="d_max" name="max_messages" type="number" min="1"
|
||||||
value="{{.DomainRLMax}}" placeholder="1000">
|
value="{{.DomainRLMax}}" placeholder="1000">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
<label for="d_win">Window (seconds)</label>
|
<label for="d_win">Window (seconds)</label>
|
||||||
<input id="d_win" name="window_seconds" type="number" min="1" value="{{.DomainRLWin}}">
|
<input id="d_win" name="window_seconds" type="number" min="1" value="{{.DomainRLWin}}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="submit">Save limit</button>
|
<button type="submit">Save limit</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -328,11 +372,12 @@
|
|||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="split">
|
||||||
<div class="card" id="export">
|
<div class="card" id="export">
|
||||||
<h2>Export domain</h2>
|
<h2>Export domain</h2>
|
||||||
<p class="muted">DKIM key, selector and application passwords for another
|
|
||||||
SelfPost instance. DNS stays the same on import.</p>
|
|
||||||
<p class="muted"><strong>Secret file</strong> — transfer securely, or encrypt
|
<p class="muted"><strong>Secret file</strong> — transfer securely, or encrypt
|
||||||
below as <code>.spde</code>.</p>
|
below as <code>.spde</code>.</p>
|
||||||
{{if .ExportErr}}<p class="error">{{.ExportErr}}</p>{{end}}
|
{{if .ExportErr}}<p class="error">{{.ExportErr}}</p>{{end}}
|
||||||
@@ -341,30 +386,11 @@
|
|||||||
<button type="submit">Export domain</button>
|
<button type="submit">Export domain</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" id="danger">
|
<div class="card" id="danger">
|
||||||
<h2>Danger zone</h2>
|
<h2>Danger zone</h2>
|
||||||
<p class="muted">Deletes the DKIM key and every application on this domain.</p>
|
<p class="muted">Deletes the DKIM key and every application on this domain.</p>
|
||||||
<a class="danger" href="/domains/{{.Domain.ID}}/delete">Delete domain</a>
|
<a class="danger" href="/domains/{{.Domain.ID}}/delete">Delete domain</a>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{/* Section index in the navigation column (layout.html). The freshly generated
|
|
||||||
password is only listed while it is on the page. */}}
|
|
||||||
{{define "sections"}}
|
|
||||||
<div class="sections">
|
|
||||||
<p class="sections-title">On this page</p>
|
|
||||||
{{if .NewCred}}<a href="#new-credential">New application password</a>{{end}}
|
|
||||||
<a href="#dkim">DKIM DNS record</a>
|
|
||||||
<a href="#dns-status">DNS status</a>
|
|
||||||
<a href="#spf">SPF record</a>
|
|
||||||
<a href="#dmarc">DMARC record</a>
|
|
||||||
<a href="#settings">Sending server settings</a>
|
|
||||||
<a href="#add-application">Add an application</a>
|
|
||||||
<a href="#applications">Applications</a>
|
|
||||||
<a href="#rate-limit">Sending rate limit</a>
|
|
||||||
<a href="#export">Export domain</a>
|
|
||||||
<a href="#danger">Danger zone</a>
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -61,9 +61,9 @@
|
|||||||
{{define "nav"}}
|
{{define "nav"}}
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
{{/* The order of the blocks is the order they are read in, top to bottom of
|
{{/* The order of the blocks is the order they are read in, top to bottom of
|
||||||
the column: the mark, the panel's pages, the current page's own sections,
|
the column: the mark, the panel's pages, and the session last. The mark
|
||||||
and the session last. The mark goes to /status, the page the panel opens
|
goes to /status, the page the panel opens on — named outright rather
|
||||||
on — named outright rather than as "/", which is only a redirect to it. */}}
|
than as "/", which is only a redirect to it. */}}
|
||||||
<a class="brand" href="{{if .IsGlobal}}/status{{else}}/domains{{end}}"><img src="/static/logo-compact.svg" width="220" height="100" alt="SelfPost"></a>
|
<a class="brand" href="{{if .IsGlobal}}/status{{else}}/domains{{end}}"><img src="/static/logo-compact.svg" width="220" height="100" alt="SelfPost"></a>
|
||||||
<div class="links">
|
<div class="links">
|
||||||
{{if .IsGlobal}}
|
{{if .IsGlobal}}
|
||||||
@@ -78,7 +78,6 @@
|
|||||||
{{if eq .Active "users"}}<span aria-current="page">{{template "icon-users"}}Users</span>{{else}}<a href="/users">{{template "icon-users"}}Users</a>{{end}}
|
{{if eq .Active "users"}}<span aria-current="page">{{template "icon-users"}}Users</span>{{else}}<a href="/users">{{template "icon-users"}}Users</a>{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{template "sections" .}}
|
|
||||||
<div class="session">
|
<div class="session">
|
||||||
<span class="session-user muted">{{template "icon-account"}}User: {{.User}}</span>
|
<span class="session-user muted">{{template "icon-account"}}User: {{.User}}</span>
|
||||||
{{if eq .Active "settings"}}<span aria-current="page">{{template "icon-settings"}}Settings</span>{{else}}<a href="/settings">{{template "icon-settings"}}Settings</a>{{end}}
|
{{if eq .Active "settings"}}<span aria-current="page">{{template "icon-settings"}}Settings</span>{{else}}<a href="/settings">{{template "icon-settings"}}Settings</a>{{end}}
|
||||||
@@ -89,22 +88,12 @@
|
|||||||
</nav>
|
</nav>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{/* The index of the current page's own sections, shown in the navigation
|
|
||||||
column under the page entries. Most pages are one or two cards and need no
|
|
||||||
index, so the block is empty here and only the pages that are genuinely
|
|
||||||
long — the domain page, the status page — redefine it with their own list
|
|
||||||
(see .sections in panel.css for what it looks like, and panel.js for the
|
|
||||||
marking of the section in view). Each page's template file is parsed after
|
|
||||||
this one, so its definition replaces this empty one; a page that defines
|
|
||||||
nothing keeps it and renders no index. */}}
|
|
||||||
{{define "sections"}}{{end}}
|
|
||||||
|
|
||||||
{{/* Whether the page takes the column whole. Empty here, so a page is held to
|
{{/* Whether the page takes the column whole. Empty here, so a page is held to
|
||||||
the reading measure unless it says otherwise; the three pages that are
|
the reading measure unless it says otherwise; the pages that are tables of
|
||||||
tables of data or raw log lines redefine this as the word "wide", which
|
data, raw log lines or side-by-side cards redefine this as the word "wide",
|
||||||
lands in <main>'s class list (see main.wide in panel.css). Same mechanism
|
which lands in <main>'s class list (see main.wide in panel.css). Each
|
||||||
as "sections" above: each page's template file is parsed after this one,
|
page's template file is parsed after this one, so its definition replaces
|
||||||
so its definition replaces the empty one. */}}
|
the empty one. */}}
|
||||||
{{define "wide"}}{{end}}
|
{{define "wide"}}{{end}}
|
||||||
|
|
||||||
{{/* back_link — up-navigation on drill-down pages. Invoke with the "back"
|
{{/* back_link — up-navigation on drill-down pages. Invoke with the "back"
|
||||||
|
|||||||
@@ -29,80 +29,6 @@ func TestEveryPageResolvesNav(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The section index each long page shows in the navigation column works by
|
|
||||||
// overriding an empty "sections" block defined in the layout, which only holds
|
|
||||||
// as long as the layout is parsed before the page's own files (see pageFiles).
|
|
||||||
// Reverse that order and every index would silently disappear — the empty
|
|
||||||
// definition would win and no page would fail to render — so the two ends are
|
|
||||||
// asserted here: the long pages produce a list, and a page that defines nothing
|
|
||||||
// produces nothing at all.
|
|
||||||
func TestSectionIndexIsOnTheLongPagesOnly(t *testing.T) {
|
|
||||||
engine, err := New("test")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("New: %v", err)
|
|
||||||
}
|
|
||||||
// Anchors the index links to, taken from the page's own cards. Status used
|
|
||||||
// to carry one too; the paired layout is short enough without it.
|
|
||||||
wantAnchors := map[string]string{
|
|
||||||
"domain_detail": `href="#danger"`,
|
|
||||||
}
|
|
||||||
for name, page := range engine.Pages() {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
// The domain page's index hides the freshly generated credential entry
|
|
||||||
// unless one is on the page, so the data map carries the key it reads.
|
|
||||||
if err := page.ExecuteTemplate(&buf, "sections", map[string]any{"NewCred": nil}); err != nil {
|
|
||||||
t.Fatalf("execute sections for %q: %v", name, err)
|
|
||||||
}
|
|
||||||
out := buf.String()
|
|
||||||
anchor, wanted := wantAnchors[name]
|
|
||||||
switch {
|
|
||||||
case wanted && !strings.Contains(out, anchor):
|
|
||||||
t.Errorf("page %q shows no section index (expected %s):\n%s", name, anchor, out)
|
|
||||||
case !wanted && strings.TrimSpace(out) != "":
|
|
||||||
t.Errorf("page %q is not long enough to carry a section index:\n%s", name, out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A section link that points at no card is a link that does nothing, and
|
|
||||||
// nothing about rendering the page says so. Every anchor the index offers must
|
|
||||||
// name an element the same page defines an id for.
|
|
||||||
func TestSectionLinksPointAtCardsThatExist(t *testing.T) {
|
|
||||||
engine, err := New("test")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("New: %v", err)
|
|
||||||
}
|
|
||||||
// The pages that carry an index; checked with a credential shown, which is
|
|
||||||
// the domain page's one conditional entry. Status dropped its index once
|
|
||||||
// the paired layout was short enough.
|
|
||||||
for _, name := range []string{"domain_detail"} {
|
|
||||||
var index bytes.Buffer
|
|
||||||
if err := engine.Page(name).ExecuteTemplate(&index, "sections", map[string]any{"NewCred": true}); err != nil {
|
|
||||||
t.Fatalf("execute sections for %q: %v", name, err)
|
|
||||||
}
|
|
||||||
// The cards are spread over the page's template files, so the ids are
|
|
||||||
// collected from the files rather than from a rendered page — rendering
|
|
||||||
// one would need the whole of a handler's data map.
|
|
||||||
ids := map[string]bool{}
|
|
||||||
for _, file := range pageFiles[name] {
|
|
||||||
body, err := fs.ReadFile(assetsFS, file)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read %s: %v", file, err)
|
|
||||||
}
|
|
||||||
// Cards only: a form field's id is not somewhere a section link may
|
|
||||||
// land, so matching those too would weaken the check.
|
|
||||||
for _, m := range regexp.MustCompile(`class="card[^"]*" id="([a-z-]+)"`).FindAllStringSubmatch(string(body), -1) {
|
|
||||||
ids[m[1]] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, m := range regexp.MustCompile(`href="#([a-z-]+)"`).FindAllStringSubmatch(index.String(), -1) {
|
|
||||||
if !ids[m[1]] {
|
|
||||||
t.Errorf("page %q indexes #%s, which no card on it carries", name, m[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The version comes from render(), not from each handler's data map, so the
|
// The version comes from render(), not from each handler's data map, so the
|
||||||
// footer is only correct as long as every page composes with the layout and
|
// footer is only correct as long as every page composes with the layout and
|
||||||
// render keeps supplying the key. Both are asserted here rather than trusted.
|
// render keeps supplying the key. Both are asserted here rather than trusted.
|
||||||
@@ -280,29 +206,46 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The domain page pairs cards the same way Status does: four .split rows
|
// The domain page pairs cards the same way Status does: three .split rows
|
||||||
// (DKIM|DNS, SPF|DMARC, settings|add-app, rate-limit|export) with Applications
|
// (DKIM|SPF+DMARC, settings|add-app, export|danger). DNS status, Applications
|
||||||
// and Danger full-width. Losing a row silently stacks the page again.
|
// and Domain settings are full-width; DNS status and Domain settings (and the
|
||||||
|
// application Edit panel) use .check-cols. Losing a row silently stacks again.
|
||||||
func TestDomainDetailPageHasPairedCards(t *testing.T) {
|
func TestDomainDetailPageHasPairedCards(t *testing.T) {
|
||||||
body, err := fs.ReadFile(assetsFS, "templates/domain_detail.html")
|
body, err := fs.ReadFile(assetsFS, "templates/domain_detail.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read domain_detail: %v", err)
|
t.Fatalf("read domain_detail: %v", err)
|
||||||
}
|
}
|
||||||
src := string(body)
|
src := string(body)
|
||||||
if got := strings.Count(src, `class="split"`); got != 4 {
|
if got := strings.Count(src, `class="split"`); got != 3 {
|
||||||
t.Errorf("domain detail has %d .split rows, want 4", got)
|
t.Errorf("domain detail has %d .split rows, want 3", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(src, `class="check-cols"`) {
|
||||||
|
t.Error("domain detail is missing the check-cols grid")
|
||||||
|
}
|
||||||
|
if !strings.Contains(src, `class="panel-toggle t-edit"`) {
|
||||||
|
t.Error("application Edit should be a single panel-toggle")
|
||||||
|
}
|
||||||
|
if strings.Contains(src, `panel-toggle t-mode`) || strings.Contains(src, `panel-toggle t-limit`) ||
|
||||||
|
strings.Contains(src, `panel-mode`) || strings.Contains(src, `panel-limit`) {
|
||||||
|
t.Error("application Edit mode and Rate limit should be one Edit button")
|
||||||
}
|
}
|
||||||
for _, id := range []string{
|
for _, id := range []string{
|
||||||
`id="dkim"`, `id="dns-status"`, `id="spf"`, `id="dmarc"`,
|
`id="dkim"`, `id="dns-status"`, `id="spf-dmarc"`,
|
||||||
`id="settings"`, `id="add-application"`, `id="applications"`,
|
`id="settings"`, `id="add-application"`, `id="applications"`,
|
||||||
`id="rate-limit"`, `id="export"`, `id="danger"`,
|
`id="domain-settings"`, `id="export"`, `id="danger"`,
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(src, id) {
|
if !strings.Contains(src, id) {
|
||||||
t.Errorf("domain detail is missing %s", id)
|
t.Errorf("domain detail is missing %s", id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.Contains(src, `id="spf-dmarc"`) {
|
if strings.Contains(src, `id="rate-limit"`) {
|
||||||
t.Error("domain detail still has the combined spf-dmarc card; SPF and DMARC are separate")
|
t.Error("domain rate limit should live inside domain-settings, not its own card")
|
||||||
|
}
|
||||||
|
if strings.Contains(src, `id="spf"`) && !strings.Contains(src, `id="spf-dmarc"`) {
|
||||||
|
t.Error("standalone SPF card should be merged into spf-dmarc")
|
||||||
|
}
|
||||||
|
if regexp.MustCompile(`id="dmarc"`).MatchString(src) {
|
||||||
|
t.Error("standalone DMARC card should be merged into spf-dmarc")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user