Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions internal/web/assets/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -1584,6 +1584,29 @@ button.top-stats-row { width: 100%; border-width: 0 0 1px; background: none; col
.record-open-button .nav-icon { width: 1rem; height: 1rem; }
.record-open-placeholder { width: 1.5rem; min-width: 1.5rem; justify-self: end; }
.isotope-zone-dialog { width: min(32rem, calc(100vw - 2rem)); }
.zone-type-fieldset { display: grid; min-width: 0; gap: var(--space-2); margin: 0; border: 0; padding: 0; }
.zone-type-fieldset legend { margin-bottom: .35rem; padding: 0; font-size: .8rem; font-weight: 500; }
.zone-type-fieldset > .field-help { margin: 0; }
.zone-type-segments { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-radius: 999px; background: var(--muted); padding: var(--space-1); }
.blocking-dialog-form .zone-type-segment { display: flex; border: 0; background: transparent; min-width: 0; height: 2rem; align-items: center; justify-content: center; gap: .35rem; border-radius: 999px; padding: 0 var(--space-2); color: var(--muted-foreground); font-size: .75rem; font-weight: 600; white-space: nowrap; cursor: pointer; transition: color 150ms ease, background-color 150ms ease, box-shadow 150ms ease; }
.zone-type-segment:hover { color: var(--foreground); }
.zone-type-segment:has(> input:checked), .zone-type-fieldset:has(.zone-type-more-panel input:checked) [data-zone-type-more-toggle] { background: var(--background); color: var(--foreground); box-shadow: 0 1px 3px rgb(0 0 0 / .16); }
.zone-type-segment:has(> input:focus-visible) { outline: 2px solid var(--ring); outline-offset: -2px; }
.zone-type-segment > span:first-of-type { overflow: hidden; text-overflow: ellipsis; }
.zone-type-more-chevron { width: .4rem; height: .4rem; flex: none; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: translateY(-.1rem) rotate(45deg); transition: transform 150ms ease; }
[data-zone-type-more-toggle][aria-expanded="true"] .zone-type-more-chevron { transform: translateY(.1rem) rotate(225deg); }
.zone-type-more-panel { display: grid; border: 1px solid var(--border); border-radius: calc(var(--radius) - 2px); padding: .3rem; }
.zone-type-more-panel[hidden] { display: none; }
.zone-type-more-panel:not([hidden]) + .field-help { display: none; }
.blocking-dialog-form .zone-type-more-option { display: grid; gap: .15rem; border-radius: .4rem; padding: .5rem .6rem; font-weight: 400; cursor: pointer; }
.zone-type-more-option:hover, .zone-type-more-option:has(input:focus-visible) { background: var(--accent); }
.zone-type-more-option:has(input:checked) { background: color-mix(in oklch, var(--accent) 72%, transparent); }
.zone-type-more-option:has(input:disabled) { opacity: .5; cursor: not-allowed; }
.zone-type-more-option strong { font-size: .8rem; font-weight: 600; }
.zone-type-more-option small { color: var(--muted-foreground); font-size: .7rem; line-height: 1.4; }
@media (prefers-reduced-motion: reduce) { .blocking-dialog-form .zone-type-segment, .zone-type-more-chevron { transition: none; } }
@media (max-width: 639px) { .blocking-dialog-form .zone-type-segment { padding: 0 var(--space-1); font-size: .7rem; } }
.zone-create-options .field-help, .blocking-dialog-form label > .field-help { margin: 0; }
.zone-settings-dialog { max-height: min(90vh, 48rem); overflow: hidden; }
.zone-settings-dialog[open] { grid-template-rows: auto auto minmax(0, 1fr); }
.zone-settings-tabs { margin: 0; }
Expand Down
112 changes: 89 additions & 23 deletions internal/web/assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2973,6 +2973,91 @@
sync();
};

// syncZoneCreateType shows the Create Zone fields the picked type uses and
// disables the rest, so a hidden field is never submitted.
const syncZoneCreateType = (form) => {
const picked = form?.querySelector("[data-zone-create-type]:checked");
if (!picked) return;
const type = picked.value;
const sectionsByType = {
primary: ["tsig"],
secondary: ["transfer", "tsig"],
stub: ["transfer", "resolution", "tsig"],
forwarder: ["forwarder", "resolution"],
alias: ["alias"],
catalog: ["tsig"],
secondary_catalog: ["transfer", "tsig"],
};
const active = sectionsByType[type] || [];
form.querySelectorAll("[data-zone-create-options]").forEach((section) => {
const shown = active.includes(section.dataset.zoneCreateOptions);
section.hidden = !shown;
section.querySelectorAll("input, textarea, select").forEach((field) => { field.disabled = !shown; });
});
const primaryProtocol = form.querySelector("[data-zone-primary-protocol]");
const stubProtocol = primaryProtocol?.querySelector("[data-stub-protocol]");
if (stubProtocol) stubProtocol.disabled = type !== "stub";
if (primaryProtocol && type === "stub" && primaryProtocol.value === "tcp") primaryProtocol.value = "udp";
if (primaryProtocol && (type === "secondary" || type === "secondary_catalog") && primaryProtocol.value === "udp") primaryProtocol.value = "tcp";
// The help line describes the picked type, and More takes the name of a
// less common type once it is picked, so the bar always shows the choice.
const description = form.querySelector("[data-zone-type-description]");
if (description) description.textContent = picked.dataset.description || "";
const more = form.querySelector("[data-zone-type-more]");
const moreLabel = form.querySelector("[data-zone-type-more-label]");
if (moreLabel) moreLabel.textContent = more?.contains(picked) ? picked.dataset.title : "More";
toggleZoneTypeMore(form, false);
};

const toggleZoneTypeMore = (form, open) => {
const panel = form?.querySelector("[data-zone-type-more]");
const toggle = form?.querySelector("[data-zone-type-more-toggle]");
if (!panel || !toggle) return;
panel.hidden = !open;
toggle.setAttribute("aria-expanded", String(open));
};

// zoneNameProblem says what is wrong with a zone name before the server has
// to, including a zone that already exists on this page's list.
const zoneNameProblem = (form, raw) => {
const value = raw.trim().toLowerCase().replace(/^\.+|\.+$/g, "");
if (!value) return "Enter the domain this zone answers for.";
if (/^[a-z][a-z0-9+.-]*:\/\//.test(value) || value.includes("/")) return "Enter just the domain, without a scheme or path.";
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(value) || value.includes(":")) return "Enter a domain, not an IP address. Reverse zones end in in-addr.arpa or ip6.arpa.";
if (!/^[\p{L}\p{N}-]+(\.[\p{L}\p{N}-]+)*$/u.test(value)) return "Use letters, numbers, and hyphens, with dots between labels.";
if (value.split(".").some((label) => label.length > 63)) return "Each label between dots can be at most 63 characters.";
const existing = form.closest("#zones-content")?.querySelector(`[data-zone-row][data-zone-name="${CSS.escape(value)}"]`);
if (existing) return `A zone named ${value} already exists.`;
return "";
};

const setupCreateZoneForm = (form) => {
if (form.dataset.zoneCreateReady === "true") return;
form.dataset.zoneCreateReady = "true";
const name = form.querySelector("[data-zone-create-name]");
const error = form.querySelector("[data-zone-create-name-error]");
const validate = (show) => {
const message = zoneNameProblem(form, name.value);
name.setCustomValidity(message);
name.toggleAttribute("aria-invalid", Boolean(message) && show);
error.textContent = message;
error.hidden = !message || !show;
};
name.addEventListener("input", () => validate(!error.hidden));
name.addEventListener("blur", () => { if (name.value.trim()) validate(true); });
name.addEventListener("invalid", () => validate(true));
form.querySelector("[data-zone-type-more-toggle]")?.addEventListener("click", (event) => {
toggleZoneTypeMore(form, event.currentTarget.getAttribute("aria-expanded") !== "true");
});
// Closing the dialog resets the form, which puts Primary back without a
// change event.
form.addEventListener("reset", () => queueMicrotask(() => {
validate(false);
syncZoneCreateType(form);
}));
syncZoneCreateType(form);
};

const setupCreateUserForm = (form) => {
if (!form || form.dataset.userValidationReady === "true") return;
form.dataset.userValidationReady = "true";
Expand Down Expand Up @@ -3443,6 +3528,7 @@
["[data-dialog-tabs]", setupDialogTabs],
["[data-cluster-tls-accordion]", setupClusterTLSAccordion],
["[data-create-user-form]", setupCreateUserForm],
["[data-zone-create-form]", setupCreateZoneForm],
["[data-unifi-auth-root]", setupUniFiAuthModes],
["[data-unifi-zone-rows]", setupUniFiZoneRows],
["[data-log-live-panel]", setupLogLivePanel],
Expand Down Expand Up @@ -4449,7 +4535,8 @@
window.history.pushState({sableDialog: dialog.id}, "", dialog.dataset.dialogUrl);
}
}
const initial = dialog.querySelector('[autofocus], input:not([type="hidden"]):not(.sr-only):not([disabled]), textarea:not([disabled]), select:not([disabled])') ||
const initial = dialog.querySelector("[autofocus]:not([disabled])") ||
dialog.querySelector('input:not([type="hidden"]):not(.sr-only):not([disabled]), textarea:not([disabled]), select:not([disabled])') ||
dialog.querySelector('button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])');
initial?.focus();
dialog.querySelector("[data-token-owner]")?.dispatchEvent(new Event("change", {bubbles: true}));
Expand Down Expand Up @@ -6050,28 +6137,7 @@
return;
}
if (event.target.matches("[data-zone-create-type]")) {
const form = event.target.closest("[data-zone-create-form]");
const type = event.target.value;
const sectionsByType = {
primary: [],
secondary: ["transfer"],
stub: ["transfer", "resolution"],
forwarder: ["forwarder", "resolution"],
alias: ["alias"],
catalog: [],
secondary_catalog: ["transfer"],
};
const active = sectionsByType[type] || [];
form?.querySelectorAll("[data-zone-create-options]").forEach((section) => {
const shown = active.includes(section.dataset.zoneCreateOptions);
section.hidden = !shown;
section.querySelectorAll("input, textarea, select").forEach((field) => { field.disabled = !shown; });
});
const primaryProtocol = form?.querySelector("[data-zone-primary-protocol]");
const stubProtocol = primaryProtocol?.querySelector("[data-stub-protocol]");
if (stubProtocol) stubProtocol.disabled = type !== "stub";
if (primaryProtocol && type === "stub" && primaryProtocol.value === "tcp") primaryProtocol.value = "udp";
if (primaryProtocol && (type === "secondary" || type === "secondary_catalog") && primaryProtocol.value === "udp") primaryProtocol.value = "tcp";
syncZoneCreateType(event.target.closest("[data-zone-create-form]"));
return;
}
if (event.target.matches("[data-record-more-types]")) {
Expand Down
97 changes: 85 additions & 12 deletions internal/web/pages/zones.templ
Original file line number Diff line number Diff line change
Expand Up @@ -867,17 +867,84 @@ templ ZoneCopyableText(id, text, className string) {
<span class={ "zone-copyable " + className }><code id={ id }>{ text }</code><button type="button" data-copy-target={ id } aria-label="Copy to clipboard" title="Copy to clipboard">@components.CopyIcons()</button></span>
}

// zoneKind is one of the zone types the Create Zone dialog offers.
type zoneKind struct {
Value string
Title string
Description string
// Common kinds lead the dialog; the rest wait behind More Types.
Common bool
}

// zoneKinds lists the zone types the console knows about, in the order the
// Create Zone dialog and Zone Settings show them.
var zoneKinds = []zoneKind{
{Value: "primary", Title: "Primary", Description: "Sable holds the records, and you edit them here.", Common: true},
{Value: "secondary", Title: "Secondary", Description: "A read-only copy transferred from another server.", Common: true},
{Value: "forwarder", Title: "Forwarder", Description: "Sends queries for this domain to another resolver.", Common: true},
{Value: "stub", Title: "Stub", Description: "Keeps only the NS records and asks those servers directly."},
{Value: "alias", Title: "Alias", Description: "Republishes another zone's records under this name."},
{Value: "catalog", Title: "Catalog", Description: "Lists zones here for secondary servers to pick up."},
{Value: "secondary_catalog", Title: "Secondary Catalog", Description: "Transfers a catalog and adds every zone it lists."},
}

func commonZoneKinds(common bool) []zoneKind {
kinds := make([]zoneKind, 0, len(zoneKinds))
for _, kind := range zoneKinds {
if kind.Common == common {
kinds = append(kinds, kind)
}
}
return kinds
}

// zoneKindUnavailable reports a kind the dialog cannot create yet. An alias
// zone needs a primary or secondary zone to mirror.
func zoneKindUnavailable(kind zoneKind, allowAlias bool) bool {
return kind.Value == "alias" && !allowAlias
}

func zoneKindDescription(kind zoneKind, allowAlias bool) string {
if zoneKindUnavailable(kind, allowAlias) {
return "Needs a primary or secondary zone to mirror first."
}
return kind.Description
}

// zoneKindRadio is the radio behind one zone type in the Create Zone picker.
// The segment or menu row around it draws the choice.
templ zoneKindRadio(kind zoneKind, allowAlias bool) {
<input class="sr-only" type="radio" name="type" value={ kind.Value } checked?={ kind.Value == "primary" } disabled?={ zoneKindUnavailable(kind, allowAlias) } aria-describedby="create-zone-type-help" data-zone-create-type data-title={ kind.Title } data-description={ zoneKindDescription(kind, allowAlias) }/>
}

// ZoneTypePicker is the Create Zone dialog's segmented control: the common
// zone types as segments, and the rest in a list that More opens under the
// bar. More takes the name of whichever of those is picked.
templ ZoneTypePicker(allowAlias bool) {
<fieldset class="zone-type-fieldset">
<legend>Zone Type</legend>
<div class="zone-type-segments">
for _, kind := range commonZoneKinds(true) {
<label class="zone-type-segment">@zoneKindRadio(kind, allowAlias)<span>{ kind.Title }</span></label>
}
<button class="zone-type-segment" type="button" aria-expanded="false" aria-controls="create-zone-more-types" data-zone-type-more-toggle><span data-zone-type-more-label>More</span><span class="zone-type-more-chevron" aria-hidden="true"></span></button>
</div>
<div class="zone-type-more-panel" id="create-zone-more-types" hidden data-zone-type-more>
for _, kind := range commonZoneKinds(false) {
<label class="zone-type-more-option">@zoneKindRadio(kind, allowAlias)<strong>{ kind.Title }</strong><small>{ zoneKindDescription(kind, allowAlias) }</small></label>
}
</div>
<p class="field-help" id="create-zone-type-help" data-zone-type-description>{ zoneKinds[0].Description }</p>
</fieldset>
}

// ZoneTypeOptions lists the zone types the console knows about. Zone Settings
// renders the same options behind a disabled select so a zone's type reads the
// way it was picked, rather than as a text box that looks editable.
templ ZoneTypeOptions(selected string, allowAlias bool) {
<option value="primary" selected?={ selected == "primary" }>Primary Zone</option>
<option value="secondary" selected?={ selected == "secondary" }>Secondary Zone</option>
<option value="stub" selected?={ selected == "stub" }>Stub Zone</option>
<option value="forwarder" selected?={ selected == "forwarder" }>Forwarder Zone</option>
<option value="alias" selected?={ selected == "alias" } disabled?={ !allowAlias }>Alias Zone</option>
<option value="catalog" selected?={ selected == "catalog" }>Catalog Zone</option>
<option value="secondary_catalog" selected?={ selected == "secondary_catalog" }>Secondary Catalog Zone</option>
templ ZoneTypeOptions(selected string) {
for _, kind := range zoneKinds {
<option value={ kind.Value } selected?={ selected == kind.Value }>{ kind.Title } Zone</option>
}
}

// TSIGKeySelect offers the keys configured under Settings. A zone that still
Expand All @@ -898,9 +965,8 @@ templ TSIGKeySelect(keys []string, selected string) {
templ CreateZoneDialog(aliasSources []string, tsigKeys []string) {
@components.Dialog(components.DialogProps{ID: "create-zone-dialog", TitleID: "create-zone-title", Title: "Create Zone", Description: "Add a new DNS zone to your server.", Class: "isotope-zone-dialog"}) {
<form class="blocking-dialog-form" hx-post="/ui/zones/add" hx-target="#zones-content" hx-swap="outerHTML" data-zone-create-form>
<label><span>Zone Name</span><input name="name" placeholder="example.com" autocomplete="off" required/></label>
<label><span>Zone Type</span><select name="type" data-zone-create-type data-styled-select>@ZoneTypeOptions("primary", len(aliasSources) > 0)</select></label>
<label><span>TSIG Key (optional)</span>@TSIGKeySelect(tsigKeys, "")<small>Authenticates transfers and updates. Add keys under <a class="inline-link" href="/settings?tab=tsig">Settings &rsaquo; TSIG</a>.</small></label>
@ZoneTypePicker(len(aliasSources) > 0)
<label><span>Zone Name</span><input class="mono-input" name="name" placeholder="example.com" autocomplete="off" autocapitalize="off" spellcheck="false" aria-describedby="create-zone-name-help create-zone-name-error" autofocus required data-zone-create-name/><small class="field-help" id="create-zone-name-help">The domain this zone answers for, such as home.arpa or example.com.</small><small class="field-error" id="create-zone-name-error" data-zone-create-name-error hidden></small></label>
<section class="zone-create-options" data-zone-create-options="transfer" hidden>
<label><span>Primary Name Servers</span><textarea class="mono-input" name="primary_servers" placeholder={ "192.0.2.53\nprimary.example.net:53" } disabled required></textarea><small>One address per line. Sable tries each server in order.</small></label>
<label><span>Protocol</span><select name="primary_protocol" data-zone-primary-protocol data-styled-select disabled><option value="tcp">TCP</option><option value="tls">DNS-over-TLS</option><option value="udp" data-stub-protocol>UDP</option></select></label>
Expand All @@ -920,6 +986,13 @@ templ CreateZoneDialog(aliasSources []string, tsigKeys []string) {
<input type="hidden" name="dnssec_validation_present" value="1" disabled/>
<label class="zone-dynamic-toggle"><span><strong>DNSSEC Validation</strong><small>Turn this off when the upstream servers answer for a signed delegation without serving its signatures, such as a private split-horizon copy of a public zone.</small></span><span class="switch"><input type="checkbox" name="dnssec_validation" value="true" checked disabled/><span></span></span></label>
</section>
<section class="zone-create-options" data-zone-create-options="tsig">
if len(tsigKeys) > 0 {
<label><span>TSIG Key (optional)</span>@TSIGKeySelect(tsigKeys, "")<small data-zone-create-tsig-help>Signs zone transfers and dynamic updates. Manage keys under <a class="inline-link" href="/settings?tab=tsig">Settings &rsaquo; TSIG</a>.</small></label>
} else {
<p class="field-help">To sign transfers and updates with a TSIG key, add one under <a class="inline-link" href="/settings?tab=tsig">Settings &rsaquo; TSIG</a> first.</p>
}
</section>
@components.DialogFooter("Cancel") {
@components.Button(components.ButtonProps{Label: "Create Zone", Type: "submit"})
}
Expand Down Expand Up @@ -1176,7 +1249,7 @@ templ ZoneSettingsDialog(zone ZoneView, id string) {
<input type="hidden" name="zone" value={ zone.Name }/>
<section class="dialog-tab-panel zone-settings-panel" role="tabpanel" data-dialog-panel="general">
<label><span>Zone Name</span><input class="mono-input" value={ zone.Name } readonly/></label>
<label><span>Zone Type</span><select data-styled-select disabled>@ZoneTypeOptions(zoneTypeSelectValue(zone), true)</select></label>
<label><span>Zone Type</span><select data-styled-select disabled>@ZoneTypeOptions(zoneTypeSelectValue(zone))</select></label>
<label><span>Default TTL (seconds)</span><input type="number" name="default_ttl" min="1" max="4294967295" value={ fmt.Sprint(zone.DefaultTTL) } required/></label>
if zone.CatalogManager != "" {
<section class="zone-settings-section">
Expand Down
Loading
Loading