v0.26.6
This commit is contained in:
1 parent
464ff42cfd
commit
f07429c267
53 files changed
+1049
-283
No files matched your search
+16
-4
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
(() => {
|
||||
let job = null, pending = false, loading = false, renewalLogs = {};
|
||||
let job = null, pending = false, loading = false, renewalLogs = {}, resetIssueFormJob = null;
|
||||
const picker = $("certificate-provider");
|
||||
const secrets = ["acme-secret", "acme-cf-token", "acme-ovh-application-key", "acme-ovh-application-secret", "acme-ovh-consumer-key"];
|
||||
const tabs = [...document.querySelectorAll("[data-certificate-tab]")];
|
||||
@@ -37,6 +37,13 @@
|
||||
}
|
||||
|
||||
const clearSecrets = () => { for (const id of secrets) $(id).value = ""; };
|
||||
function resetIssueForm() {
|
||||
$("acme-form").reset();
|
||||
$("acme-environment").value = "production";
|
||||
$("acme-confirm-production").required = true;
|
||||
clearSecrets();
|
||||
syncProvider();
|
||||
}
|
||||
function syncProvider() {
|
||||
const provider = picker.value, acme = !["import", "existing"].includes(provider);
|
||||
$("certificate-acme-editor").hidden = !acme;
|
||||
@@ -71,7 +78,12 @@
|
||||
log.textContent = showLog ? `${t("certbotLogs")}: ${value.log_directory}` : "";
|
||||
}
|
||||
function render(value) {
|
||||
job = value; $("acme-submit").disabled = pending || value?.status === "running";
|
||||
job = value;
|
||||
if (resetIssueFormJob && value?.kind === "issue" && value.id === resetIssueFormJob) {
|
||||
if (value.status === "ready") { resetIssueForm(); resetIssueFormJob = null; }
|
||||
else if (value.status === "failed") resetIssueFormJob = null;
|
||||
}
|
||||
$("acme-submit").disabled = pending || value?.status === "running";
|
||||
const issue = value?.kind === "issue" ? value : null;
|
||||
const renewal = value?.kind?.startsWith("renewal") ? value : null;
|
||||
renderOperation("acme-issue-status-card", "acme-job", "acme-issue-state", "acme-log-path", issue, "acmeIdle");
|
||||
@@ -152,7 +164,7 @@
|
||||
pending = true; $("acme-submit").disabled = true; const epoch = authEpoch;
|
||||
try {
|
||||
const value = await api("/api/acme/issue", { method: "POST", body: JSON.stringify(input) });
|
||||
if (epoch === authEpoch && authenticated) { render(value.job); selectTab("generate"); toast(t("acme.working")); }
|
||||
if (epoch === authEpoch && authenticated) { resetIssueFormJob = value.job?.id || null; render(value.job); selectTab("generate"); toast(t("acme.working")); }
|
||||
} catch (error) { if (epoch === authEpoch) toast(error.message, true); }
|
||||
finally {
|
||||
for (const key of ["key_secret", "api_token", "application_key", "application_secret", "consumer_key"]) delete input[key];
|
||||
@@ -175,7 +187,7 @@
|
||||
syncLocation,
|
||||
current: () => activeTab,
|
||||
reset() {
|
||||
job = null; renewalLogs = {}; $("renewal-list").replaceChildren(); $("acme-form").reset(); $("acme-environment").value = "production"; $("acme-confirm-production").required = true;
|
||||
job = null; renewalLogs = {}; resetIssueFormJob = null; $("renewal-list").replaceChildren(); resetIssueForm();
|
||||
render(null); selectTab("installed", false, false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -54,6 +54,7 @@ function translate() {
|
||||
document.querySelectorAll("[data-i18n]").forEach(e => e.textContent = t(e.dataset.i18n));
|
||||
document.querySelectorAll("[data-i18n-placeholder]").forEach(e => e.placeholder = t(e.dataset.i18nPlaceholder));
|
||||
document.querySelectorAll("[data-i18n-aria]").forEach(e => e.setAttribute("aria-label", t(e.dataset.i18nAria)));
|
||||
window.rustProxyAclProfiles?.refreshTranslations?.();
|
||||
$("language").value = languagePreference === "auto" ? "auto" : language; setPanel(currentPanel, false); syncAuthForm(); renderSession();
|
||||
window.rustProxyDrafts?.refresh();
|
||||
window.rustProxyPreferences?.refreshAppearanceUi?.();
|
||||
|
||||
@@ -135,6 +135,7 @@ function applyAdminPreferences(value, cache = true) {
|
||||
dashboard_density: applyAdminDashboardDensity(value.dashboard_density), dashboard_widgets: dashboard.order, dashboard_hidden_widgets: dashboard.hidden,
|
||||
};
|
||||
adminPreferences = normalized;
|
||||
window.rustProxyVhosts?.applyPreferences(normalized);
|
||||
if (languagePacks.size) translate();
|
||||
refreshAppearanceUi(); return normalized;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ function renderSettings() {
|
||||
if (!settingsDirty.has("response-form")) {
|
||||
const server = config.server_header || {}, compression = config.compression || {}, diagnostics = config.diagnostic_headers || {};
|
||||
$("expose-request-id").checked = !!diagnostics.request_id;
|
||||
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.26.5";
|
||||
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.26.6";
|
||||
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
|
||||
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
|
||||
}
|
||||
|
||||
+71
-29
@@ -808,42 +808,84 @@
|
||||
maxlength="64" pattern="[A-Za-z0-9_-]{1,64}" required /></label>
|
||||
<label><span data-i18n="aclProfileName">Display name</span><input id="acl-profile-name"
|
||||
maxlength="96" required /></label>
|
||||
<label class="check"><input id="acl-profile-ip-enabled" type="checkbox" /><span
|
||||
data-i18n="aclProfileIpEnabled">Enable IP ACL</span></label>
|
||||
<label><span data-i18n="aclDefault">Default action</span><select
|
||||
id="acl-profile-default">
|
||||
<option data-i18n="deny" value="deny">Deny</option>
|
||||
<option data-i18n="allow" value="allow">Allow</option>
|
||||
</select></label>
|
||||
<label><span data-i18n="vhCountryMode">Country access</span><select
|
||||
id="acl-profile-country-mode">
|
||||
<option data-i18n="vhOff" value="off">Off</option>
|
||||
<option data-i18n="vhCountryBlock" value="block">Block listed countries</option>
|
||||
<option data-i18n="vhCountryAllow" value="allow">Allow only listed countries
|
||||
</option>
|
||||
</select></label>
|
||||
</div>
|
||||
<div class="acl-profile-rules-editor">
|
||||
<div class="acl-profile-rules-heading"><span data-i18n="aclProfileRuleBuilder">IP
|
||||
rules</span><button class="quiet" data-i18n="aclAdd" id="acl-profile-rule-add"
|
||||
type="button">Add IP rule</button></div>
|
||||
<section class="acl-policy-builder" aria-labelledby="acl-policy-mode-label">
|
||||
<div class="acl-policy-step-head">
|
||||
<div><span class="acl-policy-step">1</span><div><strong id="acl-policy-mode-label"
|
||||
data-i18n="aclProfilePolicyMode">Choose the base policy</strong><p
|
||||
class="fine" data-i18n="aclProfilePolicyModeHelp">Start with a simple
|
||||
allow-list or block-list. Use custom mode only when you need mixed
|
||||
allow and deny rules.</p></div></div>
|
||||
</div>
|
||||
<div class="acl-policy-modes" id="acl-profile-mode-options">
|
||||
<label class="acl-policy-mode-card"><input checked name="acl-profile-mode"
|
||||
type="radio" value="allow_exceptions" /><span><b
|
||||
data-i18n="aclProfileModeAllowExceptions">Block by default</b><small
|
||||
data-i18n="aclProfileModeAllowExceptionsHelp">Allow selected countries
|
||||
OR selected IP/CIDR ranges.</small></span></label>
|
||||
<label class="acl-policy-mode-card"><input name="acl-profile-mode" type="radio"
|
||||
value="block_exceptions" /><span><b
|
||||
data-i18n="aclProfileModeBlockExceptions">Allow by default</b><small
|
||||
data-i18n="aclProfileModeBlockExceptionsHelp">Block selected countries
|
||||
OR selected IP/CIDR ranges.</small></span></label>
|
||||
<label class="acl-policy-mode-card acl-policy-mode-custom"><input
|
||||
name="acl-profile-mode" type="radio" value="custom" /><span><b
|
||||
data-i18n="aclProfileModeCustom">Custom</b><small
|
||||
data-i18n="aclProfileModeCustomHelp">Control default, country mode and
|
||||
every IP action manually.</small></span></label>
|
||||
</div>
|
||||
<div class="acl-policy-custom-controls" id="acl-profile-custom-controls" hidden>
|
||||
<label><span data-i18n="aclDefault">Default action</span><select
|
||||
id="acl-profile-default">
|
||||
<option data-i18n="deny" value="deny">Deny</option>
|
||||
<option data-i18n="allow" value="allow">Allow</option>
|
||||
</select></label>
|
||||
<label><span data-i18n="vhCountryMode">Country access</span><select
|
||||
id="acl-profile-country-mode">
|
||||
<option data-i18n="vhOff" value="off">Off</option>
|
||||
<option data-i18n="vhCountryBlock" value="block">Block listed countries</option>
|
||||
<option data-i18n="vhCountryAllow" value="allow">Allow only listed countries</option>
|
||||
</select></label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="acl-policy-builder">
|
||||
<div class="acl-policy-step-head"><div><span class="acl-policy-step">2</span><div><strong
|
||||
data-i18n="aclProfileCountryExceptions">Country exceptions</strong><p
|
||||
class="fine" id="acl-profile-country-help"></p></div></div><label
|
||||
class="acl-policy-switch"><input id="acl-profile-country-enabled"
|
||||
type="checkbox" /><span data-i18n="aclProfileUseCountries">Use countries</span></label></div>
|
||||
<label class="acl-policy-country-list" id="acl-profile-country-field"><span
|
||||
id="acl-profile-country-label" data-i18n="vhCountryList">Countries</span><textarea
|
||||
id="acl-profile-countries" rows="4" maxlength="1024" spellcheck="false"
|
||||
placeholder="CN PL"></textarea></label>
|
||||
</section>
|
||||
<section class="acl-policy-builder">
|
||||
<div class="acl-policy-step-head"><div><span class="acl-policy-step">3</span><div><strong
|
||||
data-i18n="aclProfileIpExceptions">IP / CIDR exceptions</strong><p
|
||||
class="fine" id="acl-profile-ip-help"></p></div></div><button class="quiet"
|
||||
data-i18n="aclProfileAddIpException" id="acl-profile-rule-add" type="button">Add
|
||||
IP exception</button></div>
|
||||
<div class="acl-profile-rule-rows" id="acl-profile-rule-rows"></div>
|
||||
<details class="acl-profile-bulk">
|
||||
<details class="acl-profile-bulk" id="acl-profile-bulk">
|
||||
<summary data-i18n="aclProfileBulkRules">Paste / import rules</summary>
|
||||
<label><span data-i18n="aclProfileRules">IP rules — one “allow|deny CIDR” per
|
||||
line</span><textarea id="acl-profile-rules" rows="6" spellcheck="false"
|
||||
placeholder="allow 10.0.0.0/8 deny 10.21.0.0/16"></textarea></label>
|
||||
placeholder="allow 10.0.0.0/8 allow 203.0.113.5/32"></textarea></label>
|
||||
<p class="fine" data-i18n="aclProfileBulkHelp">Whitespace and invisible Unicode
|
||||
separators are normalized during import.</p>
|
||||
<button class="quiet" data-i18n="aclProfileImportRules"
|
||||
id="acl-profile-import-rules" type="button">Import rules</button>
|
||||
</details>
|
||||
</div>
|
||||
<label><span data-i18n="vhCountryList">Countries</span><textarea id="acl-profile-countries"
|
||||
rows="4" maxlength="1024" spellcheck="false"
|
||||
placeholder="PL DE"></textarea></label>
|
||||
<p class="fine" data-i18n="aclProfileCombineHelp">Profile and vhost rules are restrictive: a
|
||||
request must pass both policies.</p>
|
||||
</section>
|
||||
<section class="acl-policy-preview" aria-live="polite">
|
||||
<div class="acl-policy-preview-head"><span data-i18n="aclProfilePreview">Effective policy</span><b
|
||||
id="acl-profile-preview-default"></b></div>
|
||||
<div class="acl-policy-preview-flow" id="acl-profile-preview-flow"></div>
|
||||
<p class="fine" id="acl-profile-preview-note"></p>
|
||||
</section>
|
||||
<p class="fine" data-i18n="aclProfileCombineHelp">Country and IP exceptions use OR inside
|
||||
this profile. Explicit deny rules and blocked countries win; attached profile, vhost and
|
||||
location policies must still all pass.</p>
|
||||
<div class="form-actions"><button class="primary" data-i18n="apply" type="submit">Validate,
|
||||
save and apply</button><button class="quiet" data-i18n="clear"
|
||||
id="acl-profile-reset" type="button">Clear</button></div>
|
||||
@@ -1877,7 +1919,7 @@
|
||||
<option data-i18n="serverCustom" value="custom">Replace / add custom value</option>
|
||||
</select>
|
||||
<label data-i18n="serverValue" for="server-value">Custom Server value</label><input
|
||||
id="server-value" maxlength="256" placeholder="RustProxy/v0.26.5"
|
||||
id="server-value" maxlength="256" placeholder="RustProxy/v0.26.6"
|
||||
spellcheck="false" />
|
||||
<h3 class="subheading" data-i18n="diagnosticHeaders">Diagnostic headers</h3>
|
||||
<div class="switch-grid">
|
||||
@@ -2676,7 +2718,7 @@
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="page-footer"><span>RustProxy <b>0.26.5</b></span><span id="footer-status">API - /
|
||||
<footer class="page-footer"><span>RustProxy <b>0.26.6</b></span><span id="footer-status">API - /
|
||||
dropped: 0</span></footer>
|
||||
</div>
|
||||
</main>
|
||||
@@ -3387,7 +3429,7 @@ max_execution_time = 30</code></pre>
|
||||
<article class="card about-card">
|
||||
<div class="about-product">
|
||||
<span aria-hidden="true" class="brand-mark">r<span>/</span>p</span>
|
||||
<div><span class="small-label">RustProxy</span><strong>0.26.5</strong></div>
|
||||
<div><span class="small-label">RustProxy</span><strong>0.26.6</strong></div>
|
||||
</div>
|
||||
<dl class="about-meta">
|
||||
<div>
|
||||
|
||||
+178
-46
@@ -186,73 +186,192 @@
|
||||
window.rustProxyLogs = { render, open, loadPreferences, applyPreferences, reset() { dialog.close(); paused = null; expanded = null; preferencesLoaded = false; preferencesLoading = null; preferenceSaveQueued = false; visibleColumns = defaultColumns(); syncColumnPicker(); syncHeaders(); for (const id of filters) $(id).value = id === "log-outcome" ? "all" : ""; syncExpandedButton(); } };
|
||||
})();
|
||||
|
||||
// Reusable ACL profiles. They are intentionally composed with, not substituted
|
||||
// for, the per-vhost policy so attaching a profile cannot weaken a vhost rule.
|
||||
// Reusable ACL profiles. One profile is one access policy: the guided modes
|
||||
// make country and IP/CIDR exceptions read as a single OR expression, while
|
||||
// custom mode retains the full backend model for mixed allow/deny policies.
|
||||
(() => {
|
||||
let editing = null, dirty = false;
|
||||
const form = $("acl-profile-form");
|
||||
const split = value => value.split(/[\s,]+/).map(v => v.trim()).filter(Boolean);
|
||||
const ipEnabled = $("acl-profile-ip-enabled"), aclDefault = $("acl-profile-default"), rules = $("acl-profile-rules"), ruleRows = $("acl-profile-rule-rows");
|
||||
const aclDefault = $("acl-profile-default"), countryMode = $("acl-profile-country-mode"), countriesInput = $("acl-profile-countries");
|
||||
const countryEnabled = $("acl-profile-country-enabled"), countryField = $("acl-profile-country-field"), customControls = $("acl-profile-custom-controls");
|
||||
const rules = $("acl-profile-rules"), ruleRows = $("acl-profile-rule-rows"), bulk = $("acl-profile-bulk");
|
||||
const addButton = $("acl-profile-rule-add"), importButton = $("acl-profile-import-rules");
|
||||
const i18n = (tag, key) => { const node = el(tag, t(key)); node.dataset.i18n = key; return node; };
|
||||
function addRule(rule = { action: "allow", network: "" }, mark = true) {
|
||||
const previewDefault = $("acl-profile-preview-default"), previewFlow = $("acl-profile-preview-flow"), previewNote = $("acl-profile-preview-note");
|
||||
const countryHelp = $("acl-profile-country-help"), countryLabel = $("acl-profile-country-label"), ipHelp = $("acl-profile-ip-help");
|
||||
const modeInputs = [...form.querySelectorAll('input[name="acl-profile-mode"]')];
|
||||
const i18n = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; };
|
||||
const selectedMode = () => modeInputs.find(node => node.checked)?.value || "allow_exceptions";
|
||||
const setMode = value => { for (const node of modeInputs) node.checked = node.value === value; };
|
||||
const guidedAction = mode => mode === "allow_exceptions" ? "allow" : "deny";
|
||||
const guidedDefault = mode => mode === "allow_exceptions" ? "deny" : "allow";
|
||||
const guidedCountryMode = mode => mode === "allow_exceptions" ? "allow" : "block";
|
||||
const activeCountryMode = () => selectedMode() === "custom" ? countryMode.value : (countryEnabled.checked ? guidedCountryMode(selectedMode()) : "off");
|
||||
function normalizeNetwork(value) {
|
||||
const network = value.trim();
|
||||
if (!network || network.includes("/")) return network;
|
||||
if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(network)) {
|
||||
const octets = network.split(".").map(Number);
|
||||
if (octets.every(value => value >= 0 && value <= 255)) return `${network}/32`;
|
||||
}
|
||||
if (network.includes(":")) return `${network}/128`;
|
||||
return network;
|
||||
}
|
||||
function addRule(rule = null, mark = true) {
|
||||
if (ruleRows.children.length >= 128) { toast(t("vhRowLimit"), true); return; }
|
||||
const mode = selectedMode(), fallbackAction = mode === "custom" ? "allow" : guidedAction(mode);
|
||||
const value = rule || { action: fallbackAction, network: "" };
|
||||
const row = el("div", undefined, "acl-rule acl-profile-rule");
|
||||
const actionLabel = el("label"); actionLabel.append(i18n("span", "aclAction"));
|
||||
const actionLabel = el("label", undefined, "acl-profile-rule-action-field"); actionLabel.append(i18n("span", "aclAction"));
|
||||
const actionSelect = el("select"); actionSelect.className = "acl-profile-rule-action";
|
||||
for (const value of ["allow", "deny"]) { const option = el("option", t(value === "allow" ? "aclAllow" : "vhDeny")); option.value = value; actionSelect.append(option); }
|
||||
actionSelect.value = rule.action || "allow"; actionLabel.append(actionSelect);
|
||||
for (const actionValue of ["allow", "deny"]) { const option = el("option", t(actionValue === "allow" ? "aclAllow" : "vhDeny")); option.value = actionValue; actionSelect.append(option); }
|
||||
actionSelect.value = value.action || fallbackAction; actionLabel.append(actionSelect);
|
||||
const actionBadge = el("span", "", "acl-profile-rule-action-badge"); actionLabel.append(actionBadge);
|
||||
const networkLabel = el("label"); networkLabel.append(i18n("span", "aclNetwork"));
|
||||
const network = el("input"); network.className = "acl-profile-rule-network"; network.type = "text"; network.required = true; network.maxLength = 64; network.autocomplete = "off"; network.spellcheck = false; network.placeholder = "10.0.0.0/8"; network.value = rule.network || ""; networkLabel.append(network);
|
||||
const remove = action(t("remove"), () => { row.remove(); syncRulesText(); dirty = true; syncIp(); }); remove.classList.add("acl-profile-rule-remove");
|
||||
const network = el("input"); network.className = "acl-profile-rule-network"; network.type = "text"; network.required = true; network.maxLength = 64; network.autocomplete = "off"; network.spellcheck = false; network.placeholder = "10.0.0.0/8 or 203.0.113.5"; network.value = value.network || ""; networkLabel.append(network);
|
||||
network.addEventListener("blur", () => { const normalized = normalizeNetwork(network.value); if (normalized !== network.value) { network.value = normalized; syncRulesText(); renderPreview(); } });
|
||||
const remove = action(t("remove"), () => { row.remove(); syncRulesText(); dirty = true; syncPolicy(); }); remove.classList.add("acl-profile-rule-remove");
|
||||
row.append(actionLabel, networkLabel, remove); ruleRows.append(row);
|
||||
if (mark) { syncRulesText(); dirty = true; }
|
||||
syncIp();
|
||||
syncRuleRows();
|
||||
}
|
||||
function collectRuleRows() {
|
||||
return [...ruleRows.children].map(row => ({
|
||||
action: row.querySelector(".acl-profile-rule-action").value,
|
||||
network: row.querySelector(".acl-profile-rule-network").value.trim(),
|
||||
network: normalizeNetwork(row.querySelector(".acl-profile-rule-network").value),
|
||||
}));
|
||||
}
|
||||
function syncRulesText() { rules.value = collectRuleRows().map(rule => `${rule.action} ${rule.network}`.trimEnd()).join("\n"); }
|
||||
function renderRuleRows(values) { ruleRows.replaceChildren(); for (const rule of values || []) addRule(rule, false); syncRulesText(); syncIp(); }
|
||||
function importRules() {
|
||||
try { renderRuleRows(parseRules(rules.value, rules)); dirty = true; toast(t("aclProfileRulesImported")); }
|
||||
catch (error) { toast(error.message, true); }
|
||||
function renderRuleRows(values) { ruleRows.replaceChildren(); for (const rule of values || []) addRule(rule, false); syncRulesText(); syncRuleRows(); }
|
||||
function parseBulkRules(value, target) {
|
||||
if (selectedMode() === "custom") return parseRules(value, target);
|
||||
const expected = guidedAction(selectedMode());
|
||||
return normalizeAclRuleText(value).split(/\r?\n/).map((line, index) => ({ line: line.trim(), number: index + 1 })).filter(item => item.line).map(item => {
|
||||
const match = item.line.match(/^(?:(allow|deny)\s+)?(\S+)$/i);
|
||||
if (!match) throw fieldError(target, `${t("ruleSyntaxLine")} ${item.number}: ${item.line}`);
|
||||
return { action: (match[1] || expected).toLowerCase(), network: normalizeNetwork(match[2]) };
|
||||
});
|
||||
}
|
||||
function syncIp() {
|
||||
const disabled = !ipEnabled.checked;
|
||||
aclDefault.disabled = disabled; rules.disabled = disabled; addButton.disabled = disabled; importButton.disabled = disabled;
|
||||
for (const node of ruleRows.querySelectorAll("input,select,button")) node.disabled = disabled;
|
||||
function importRules() {
|
||||
try {
|
||||
const parsed = parseBulkRules(rules.value, rules), mode = selectedMode();
|
||||
if (mode !== "custom" && parsed.some(rule => rule.action !== guidedAction(mode))) setMode("custom");
|
||||
renderRuleRows(parsed); dirty = true; syncPolicy(); toast(t("aclProfileRulesImported"));
|
||||
} catch (error) { toast(error.message, true); }
|
||||
}
|
||||
function inferMode(profile) {
|
||||
const cMode = profile.country_access?.mode || "off";
|
||||
const defaultAction = profile.acl?.default || (cMode === "allow" ? "deny" : "allow");
|
||||
const actions = (profile.acl?.rules || []).map(rule => rule.action);
|
||||
if (defaultAction === "deny" && actions.every(action => action === "allow") && ["off", "allow"].includes(cMode)) return "allow_exceptions";
|
||||
if (defaultAction === "allow" && actions.every(action => action === "deny") && ["off", "block"].includes(cMode)) return "block_exceptions";
|
||||
return "custom";
|
||||
}
|
||||
function syncRuleRows(convert = false) {
|
||||
const mode = selectedMode(), custom = mode === "custom", expected = custom ? null : guidedAction(mode);
|
||||
for (const row of ruleRows.children) {
|
||||
const select = row.querySelector(".acl-profile-rule-action"), badge = row.querySelector(".acl-profile-rule-action-badge");
|
||||
if (!custom && convert) select.value = expected;
|
||||
select.hidden = !custom; badge.hidden = custom;
|
||||
if (!custom) { badge.textContent = t(expected === "allow" ? "aclAllow" : "vhDeny"); badge.classList.toggle("is-allow", expected === "allow"); badge.classList.toggle("is-deny", expected === "deny"); }
|
||||
}
|
||||
syncRulesText();
|
||||
}
|
||||
function conditionChip(key, value, kind) {
|
||||
const chip = el("span", undefined, `acl-policy-condition ${kind}`); chip.append(i18n("b", key), el("span", value)); return chip;
|
||||
}
|
||||
function renderPreview() {
|
||||
const mode = selectedMode(), custom = mode === "custom", defaultAction = custom ? aclDefault.value : guidedDefault(mode);
|
||||
previewDefault.textContent = t(defaultAction === "deny" ? "aclProfilePreviewDefaultDeny" : "aclProfilePreviewDefaultAllow");
|
||||
previewDefault.className = defaultAction === "deny" ? "is-deny" : "is-allow";
|
||||
const countries = [...new Set(split(countriesInput.value).map(value => value.toUpperCase()))];
|
||||
const ipRules = collectRuleRows().filter(rule => rule.network);
|
||||
if (custom) {
|
||||
const modeValue = countryMode.value, items = [];
|
||||
items.push(conditionChip("aclProfilePreviewIpRules", String(ipRules.length), "neutral"));
|
||||
if (modeValue !== "off") items.push(conditionChip(modeValue === "allow" ? "aclProfilePreviewAllowedCountries" : "aclProfilePreviewBlockedCountries", countries.join(", ") || "—", modeValue === "allow" ? "allow" : "deny"));
|
||||
previewFlow.replaceChildren(...items);
|
||||
previewNote.textContent = t("aclProfilePreviewCustomNote");
|
||||
return;
|
||||
}
|
||||
const allowMode = mode === "allow_exceptions", items = [];
|
||||
if (countryEnabled.checked) items.push(conditionChip(allowMode ? "aclProfilePreviewAllowedCountries" : "aclProfilePreviewBlockedCountries", countries.join(", ") || "—", allowMode ? "allow" : "deny"));
|
||||
if (ipRules.length) items.push(conditionChip(allowMode ? "aclProfilePreviewAllowedIps" : "aclProfilePreviewBlockedIps", ipRules.map(rule => rule.network).join(", "), allowMode ? "allow" : "deny"));
|
||||
if (!items.length) items.push(el("span", t("aclProfilePreviewNoExceptions"), "acl-policy-condition neutral"));
|
||||
const flow = [];
|
||||
items.forEach((item, index) => { if (index) flow.push(el("span", t("aclProfileOr"), "acl-policy-or")); flow.push(item); });
|
||||
previewFlow.replaceChildren(...flow);
|
||||
previewNote.textContent = t(items.length === 1 && !countryEnabled.checked && !ipRules.length ? (allowMode ? "aclProfilePreviewBlocksAll" : "aclProfilePreviewAllowsAll") : (allowMode ? "aclProfilePreviewAllowNote" : "aclProfilePreviewBlockNote"));
|
||||
}
|
||||
function syncPolicy(convertRules = false) {
|
||||
const mode = selectedMode(), custom = mode === "custom";
|
||||
customControls.hidden = !custom;
|
||||
if (!custom) {
|
||||
aclDefault.value = guidedDefault(mode);
|
||||
countryMode.value = countryEnabled.checked ? guidedCountryMode(mode) : "off";
|
||||
countryLabel.textContent = t(mode === "allow_exceptions" ? "aclProfileAllowedCountries" : "aclProfileBlockedCountries");
|
||||
countryLabel.dataset.i18n = mode === "allow_exceptions" ? "aclProfileAllowedCountries" : "aclProfileBlockedCountries";
|
||||
countryHelp.textContent = t(mode === "allow_exceptions" ? "aclProfileAllowedCountriesHelp" : "aclProfileBlockedCountriesHelp");
|
||||
ipHelp.textContent = t(mode === "allow_exceptions" ? "aclProfileAllowedIpsHelp" : "aclProfileBlockedIpsHelp");
|
||||
} else {
|
||||
countryLabel.textContent = t("vhCountryList"); countryLabel.dataset.i18n = "vhCountryList";
|
||||
countryHelp.textContent = t("aclProfileCustomCountryHelp"); ipHelp.textContent = t("aclProfileCustomIpHelp");
|
||||
countryEnabled.checked = countryMode.value !== "off";
|
||||
}
|
||||
countryField.classList.toggle("is-disabled", !countryEnabled.checked); countriesInput.disabled = !countryEnabled.checked;
|
||||
if (custom) { countriesInput.disabled = countryMode.value === "off"; countryEnabled.checked = countryMode.value !== "off"; countryEnabled.disabled = true; }
|
||||
else countryEnabled.disabled = false;
|
||||
bulk.querySelector("summary").textContent = t(custom ? "aclProfileBulkRules" : "aclProfileBulkExceptions");
|
||||
rules.placeholder = custom ? "allow 10.0.0.0/8\ndeny 10.21.0.0/16" : "10.0.0.0/8\n203.0.113.5";
|
||||
syncRuleRows(convertRules); renderPreview();
|
||||
}
|
||||
function clear() {
|
||||
editing = null; dirty = false; form.reset(); ipEnabled.checked = false; aclDefault.value = "deny"; $("acl-profile-country-mode").value = "off"; $("acl-profile-id").readOnly = false; $("acl-profile-editing").textContent = t("aclProfileNew"); renderRuleRows([]); rules.value = ""; syncIp();
|
||||
editing = null; dirty = false; form.reset(); setMode("allow_exceptions"); aclDefault.value = "deny"; countryMode.value = "off"; countryEnabled.checked = false; countriesInput.value = ""; $("acl-profile-id").readOnly = false; $("acl-profile-editing").textContent = t("aclProfileNew"); renderRuleRows([]); rules.value = ""; syncPolicy();
|
||||
}
|
||||
function edit(profile) {
|
||||
editing = profile.id; dirty = false; $("acl-profile-id").value = profile.id; $("acl-profile-id").readOnly = true; $("acl-profile-name").value = profile.name || profile.id;
|
||||
ipEnabled.checked = !!profile.acl; aclDefault.value = profile.acl?.default || "deny"; renderRuleRows(profile.acl?.rules || []);
|
||||
$("acl-profile-country-mode").value = profile.country_access?.mode || "off"; $("acl-profile-countries").value = (profile.country_access?.countries || []).join("\n");
|
||||
$("acl-profile-editing").textContent = t("aclProfileEdit"); dirty = false; syncIp();
|
||||
const cMode = profile.country_access?.mode || "off"; aclDefault.value = profile.acl?.default || (cMode === "allow" ? "deny" : "allow"); countryMode.value = cMode;
|
||||
countryEnabled.checked = cMode !== "off"; countriesInput.value = (profile.country_access?.countries || []).join("\n");
|
||||
setMode(inferMode(profile)); renderRuleRows(profile.acl?.rules || []); $("acl-profile-editing").textContent = t("aclProfileEdit"); dirty = false; syncPolicy();
|
||||
}
|
||||
function friendlySummary(profile) {
|
||||
const mode = inferMode(profile), countries = profile.country_access?.countries || [], rulesList = profile.acl?.rules || [];
|
||||
if (mode === "allow_exceptions" || mode === "block_exceptions") {
|
||||
const allowMode = mode === "allow_exceptions", parts = [];
|
||||
if (countries.length && profile.country_access?.mode !== "off") parts.push(`${t(allowMode ? "aclProfileCountriesShortAllow" : "aclProfileCountriesShortBlock")}: ${countries.join(", ")}`);
|
||||
if (rulesList.length) parts.push(`${t(allowMode ? "aclProfileIpsShortAllow" : "aclProfileIpsShortBlock")}: ${rulesList.map(rule => rule.network).join(", ")}`);
|
||||
return [`${t(allowMode ? "aclProfileModeAllowExceptions" : "aclProfileModeBlockExceptions")}`, parts.join(" · ") || t("aclProfilePreviewNoExceptions")];
|
||||
}
|
||||
return [`${t("aclProfileModeCustom")}: ${t("aclDefault")} ${t(profile.acl?.default || "allow")}`, `${rulesList.length} ${t("aclProfileIpRulesShort")} · ${t("vhCountryMode")}: ${t(profile.country_access?.mode === "block" ? "vhCountryBlock" : profile.country_access?.mode === "allow" ? "vhCountryAllow" : "vhOff")}`];
|
||||
}
|
||||
function refreshTranslations() {
|
||||
$("acl-profile-editing").textContent = t(editing ? "aclProfileEdit" : "aclProfileNew");
|
||||
for (const row of ruleRows.children) {
|
||||
const select = row.querySelector(".acl-profile-rule-action");
|
||||
if (select?.options?.length >= 2) {
|
||||
select.options[0].textContent = t("aclAllow");
|
||||
select.options[1].textContent = t("vhDeny");
|
||||
}
|
||||
const remove = row.querySelector(".acl-profile-rule-remove");
|
||||
if (remove) remove.textContent = t("remove");
|
||||
}
|
||||
syncPolicy();
|
||||
render();
|
||||
}
|
||||
function render() {
|
||||
if (!envelope) return;
|
||||
const profiles = envelope.config.acl_profiles || [], list = $("acl-profile-list");
|
||||
const cards = profiles.map(profile => {
|
||||
const box = el("div", undefined, "entity");
|
||||
const box = el("div", undefined, "entity acl-profile-card");
|
||||
const top = el("div", undefined, "entity-top"); top.append(el("span", profile.name || profile.id, "entity-title"), el("small", profile.id)); box.append(top);
|
||||
const pieces = [];
|
||||
if (profile.acl) pieces.push(`${t("aclDefault")}: ${t(profile.acl.default || "deny")} · ${(profile.acl.rules || []).length} ${t("aclProfileIpRulesShort")}`);
|
||||
else pieces.push(t("aclProfileIpOff"));
|
||||
const countries = profile.country_access?.countries || [];
|
||||
pieces.push(profile.country_access?.mode && profile.country_access.mode !== "off" ? `${t("vhCountryMode")}: ${t(profile.country_access.mode === "block" ? "vhCountryBlock" : "vhCountryAllow")} · ${countries.join(", ") || "-"}` : t("aclProfileCountriesOff"));
|
||||
const pieces = friendlySummary(profile);
|
||||
const users = (envelope.config.vhosts || []).filter(v => v.acl_profile_id === profile.id).map(v => v.id);
|
||||
for (const agent of envelope.config.docker_agents?.agents || []) {
|
||||
if (agent.acl_profile_id === profile.id) users.push(`docker-agent:${agent.id}`);
|
||||
for (const assignment of agent.assignments || []) if (assignment.acl_profile_id === profile.id) users.push(`docker:${agent.id}/${assignment.hostname || assignment.service_key}`);
|
||||
}
|
||||
if (users.length) pieces.push(`${t("aclProfileAttached")}: ${users.join(", ")}`);
|
||||
box.append(el("p", pieces.join("\n"), "entity-details"));
|
||||
box.append(el("p", pieces.filter(Boolean).join("\n"), "entity-details"));
|
||||
const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => edit(profile)), action(t("remove"), async () => {
|
||||
if (users.length) { toast(`${t("aclProfileInUse")}: ${users.join(", ")}`, true); return; }
|
||||
if (!(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.acl_profiles = (next.acl_profiles || []).filter(item => item.id !== profile.id); if (await saveSection("acl-profiles", next.acl_profiles || [])) clear();
|
||||
@@ -261,32 +380,45 @@
|
||||
list.replaceChildren(...(cards.length ? cards : [el("p", t("aclProfilesEmpty"), "empty")]));
|
||||
if (!dirty && editing) { const current = profiles.find(p => p.id === editing); if (current) edit(current); else clear(); }
|
||||
}
|
||||
form.addEventListener("input", () => { dirty = true; }); form.addEventListener("change", () => { dirty = true; });
|
||||
ruleRows.addEventListener("input", syncRulesText); ruleRows.addEventListener("change", syncRulesText);
|
||||
ipEnabled.addEventListener("change", () => { if (ipEnabled.checked && !ruleRows.children.length) addRule(); syncIp(); });
|
||||
addButton.addEventListener("click", () => addRule()); importButton.addEventListener("click", importRules);
|
||||
form.addEventListener("input", event => { dirty = true; if (event.target !== rules) { syncRulesText(); syncPolicy(); } });
|
||||
form.addEventListener("change", event => {
|
||||
dirty = true;
|
||||
if (event.target.matches('input[name="acl-profile-mode"]')) syncPolicy(true);
|
||||
else if (event.target === countryEnabled || event.target === countryMode || event.target === aclDefault) syncPolicy();
|
||||
else { syncRulesText(); syncPolicy(); }
|
||||
});
|
||||
ruleRows.addEventListener("input", () => { syncRulesText(); renderPreview(); }); ruleRows.addEventListener("change", () => { syncRulesText(); renderPreview(); });
|
||||
addButton.addEventListener("click", () => { addRule(); syncPolicy(); }); importButton.addEventListener("click", importRules);
|
||||
form.addEventListener("submit", async event => {
|
||||
event.preventDefault(); if (!envelope || busy) return;
|
||||
try {
|
||||
await withFormSubmitState(form, event.submitter, async () => {
|
||||
const id = $("acl-profile-id").value.trim(), name = $("acl-profile-name").value.trim();
|
||||
const countries = [...new Set(split($("acl-profile-countries").value).map(v => v.toUpperCase()))];
|
||||
if (countries.some(v => !/^[A-Z]{2}$/.test(v))) throw fieldError($("acl-profile-countries"), t("aclProfileCountryInvalid"));
|
||||
// If the bulk textarea was edited directly, apply it before saving instead of
|
||||
// silently ignoring pasted content. The parser reports the exact bad line.
|
||||
const id = $("acl-profile-id").value.trim(), name = $("acl-profile-name").value.trim(), mode = selectedMode();
|
||||
const countries = [...new Set(split(countriesInput.value).map(v => v.toUpperCase()))];
|
||||
if (countries.some(v => !/^[A-Z]{2}$/.test(v))) throw fieldError(countriesInput, t("aclProfileCountryInvalid"));
|
||||
const cMode = activeCountryMode();
|
||||
if (cMode !== "off" && !countries.length) throw fieldError(countriesInput, t("aclProfileCountryRequired"));
|
||||
const visualText = collectRuleRows().map(rule => `${rule.action} ${rule.network}`.trimEnd()).join("\n");
|
||||
if (ipEnabled.checked && normalizeAclRuleText(rules.value).trim() !== normalizeAclRuleText(visualText).trim()) renderRuleRows(parseRules(rules.value, rules));
|
||||
const aclRules = collectRuleRows();
|
||||
if (ipEnabled.checked && aclRules.some(rule => !rule.network)) { const input = [...ruleRows.querySelectorAll(".acl-profile-rule-network")].find(node => !node.value.trim()); throw fieldError(input || rules, t("ruleSyntax")); }
|
||||
const profile = { id, name, acl: ipEnabled.checked ? { default: aclDefault.value, rules: aclRules } : null, country_access: { mode: $("acl-profile-country-mode").value, countries } };
|
||||
if (normalizeAclRuleText(rules.value).trim() !== normalizeAclRuleText(visualText).trim()) {
|
||||
const parsed = parseBulkRules(rules.value, rules);
|
||||
if (mode !== "custom" && parsed.some(rule => rule.action !== guidedAction(mode))) { setMode("custom"); syncPolicy(); }
|
||||
renderRuleRows(parsed);
|
||||
}
|
||||
const finalMode = selectedMode(), expectedAction = finalMode === "custom" ? null : guidedAction(finalMode);
|
||||
const aclRules = collectRuleRows().map(rule => ({ ...rule, action: expectedAction || rule.action }));
|
||||
if (aclRules.some(rule => !rule.network)) { const input = [...ruleRows.querySelectorAll(".acl-profile-rule-network")].find(node => !node.value.trim()); throw fieldError(input || rules, t("ruleSyntax")); }
|
||||
for (const [row, rule] of [...ruleRows.children].map((row, index) => [row, aclRules[index]])) row.querySelector(".acl-profile-rule-network").value = rule.network;
|
||||
const finalDefault = finalMode === "custom" ? aclDefault.value : guidedDefault(finalMode);
|
||||
const finalCountryMode = finalMode === "custom" ? countryMode.value : (countryEnabled.checked ? guidedCountryMode(finalMode) : "off");
|
||||
const profile = { id, name, acl: { default: finalDefault, rules: aclRules }, country_access: { mode: finalCountryMode, countries } };
|
||||
const next = structuredClone(envelope.config); next.acl_profiles ||= [];
|
||||
const index = next.acl_profiles.findIndex(item => item.id === editing);
|
||||
if (index < 0) { if (next.acl_profiles.some(item => item.id === id)) throw fieldError($("acl-profile-id"), t("aclProfileDuplicate")); next.acl_profiles.push(profile); } else next.acl_profiles[index] = profile;
|
||||
if (await saveSection("acl-profiles", next.acl_profiles || [])) { editing = id; dirty = false; render(); }
|
||||
});
|
||||
} catch (error) { toast(error.message, true); }
|
||||
} catch (error) { toast(error.message, true); if (error.fieldTarget?.focus) error.fieldTarget.focus(); }
|
||||
});
|
||||
$("acl-profile-new").addEventListener("click", clear); $("acl-profile-reset").addEventListener("click", clear);
|
||||
window.rustProxyAclProfiles = { render, reset: clear };
|
||||
window.rustProxyAclProfiles = { render, reset: clear, refreshTranslations };
|
||||
clear(); if (envelope) render();
|
||||
})();
|
||||
@@ -9386,3 +9386,235 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
min-height: var(--toolbar-control-height);
|
||||
padding-block: 0
|
||||
}
|
||||
|
||||
/* ACL policy builder: guided OR-composition for country + IP exceptions. */
|
||||
.acl-profile-layout {
|
||||
grid-template-columns: minmax(260px, .72fr) minmax(440px, 1.28fr)
|
||||
}
|
||||
|
||||
.acl-policy-builder,
|
||||
.acl-policy-preview {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg)
|
||||
}
|
||||
|
||||
.acl-policy-step-head,
|
||||
.acl-policy-step-head>div,
|
||||
.acl-policy-preview-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px
|
||||
}
|
||||
|
||||
.acl-policy-step-head>div {
|
||||
justify-content: flex-start
|
||||
}
|
||||
|
||||
.acl-policy-step-head strong,
|
||||
.acl-policy-preview-head>span {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text)
|
||||
}
|
||||
|
||||
.acl-policy-step-head p {
|
||||
margin: 3px 0 0
|
||||
}
|
||||
|
||||
.acl-policy-step {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
flex: 0 0 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
color: var(--muted);
|
||||
font: 11px var(--mono)
|
||||
}
|
||||
|
||||
.acl-policy-modes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 10px
|
||||
}
|
||||
|
||||
.acl-policy-mode-card {
|
||||
position: relative;
|
||||
display: flex !important;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
margin: 0 !important;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--field-line);
|
||||
background: var(--field-bg);
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
.acl-policy-mode-card:has(input:checked) {
|
||||
outline: 1px solid var(--green);
|
||||
border-color: var(--green)
|
||||
}
|
||||
|
||||
.acl-policy-mode-card input {
|
||||
margin-top: 2px
|
||||
}
|
||||
|
||||
.acl-policy-mode-card span {
|
||||
display: grid;
|
||||
gap: 3px
|
||||
}
|
||||
|
||||
.acl-policy-mode-card b {
|
||||
color: var(--text);
|
||||
font-size: 12px
|
||||
}
|
||||
|
||||
.acl-policy-mode-card small {
|
||||
color: var(--muted);
|
||||
line-height: 1.45
|
||||
}
|
||||
|
||||
.acl-policy-mode-custom {
|
||||
grid-column: 1 / -1
|
||||
}
|
||||
|
||||
.acl-policy-custom-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 10px;
|
||||
margin-top: 6px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--line)
|
||||
}
|
||||
|
||||
.acl-policy-custom-controls[hidden] {
|
||||
display: none
|
||||
}
|
||||
|
||||
.acl-policy-switch {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 0 !important;
|
||||
white-space: nowrap
|
||||
}
|
||||
|
||||
.acl-policy-country-list.is-disabled {
|
||||
opacity: .55
|
||||
}
|
||||
|
||||
.acl-profile-rule-action-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--ui-field-min-height);
|
||||
padding: 7px 9px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
font-size: 11px;
|
||||
font-weight: 700
|
||||
}
|
||||
|
||||
.acl-profile-rule-action-badge.is-allow,
|
||||
.acl-policy-preview-head .is-allow {
|
||||
color: var(--green)
|
||||
}
|
||||
|
||||
.acl-profile-rule-action-badge.is-deny,
|
||||
.acl-policy-preview-head .is-deny {
|
||||
color: var(--red)
|
||||
}
|
||||
|
||||
.acl-policy-preview {
|
||||
display: grid;
|
||||
gap: 9px
|
||||
}
|
||||
|
||||
.acl-policy-preview-head {
|
||||
align-items: center
|
||||
}
|
||||
|
||||
.acl-policy-preview-head b {
|
||||
padding: 4px 7px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
font: 700 10px var(--mono)
|
||||
}
|
||||
|
||||
.acl-policy-preview-flow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px
|
||||
}
|
||||
|
||||
.acl-policy-condition {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
font-size: 11px
|
||||
}
|
||||
|
||||
.acl-policy-condition b {
|
||||
font-size: 10px;
|
||||
color: var(--muted)
|
||||
}
|
||||
|
||||
.acl-policy-condition span {
|
||||
overflow-wrap: anywhere
|
||||
}
|
||||
|
||||
.acl-policy-condition.allow {
|
||||
border-left: 3px solid var(--green)
|
||||
}
|
||||
|
||||
.acl-policy-condition.deny {
|
||||
border-left: 3px solid var(--red)
|
||||
}
|
||||
|
||||
.acl-policy-or {
|
||||
color: var(--muted);
|
||||
font: 700 10px var(--mono)
|
||||
}
|
||||
|
||||
.acl-policy-preview .fine {
|
||||
margin: 0
|
||||
}
|
||||
|
||||
.acl-profile-card .entity-details {
|
||||
white-space: pre-line
|
||||
}
|
||||
|
||||
@media(max-width:900px) {
|
||||
.acl-profile-layout,
|
||||
.acl-policy-modes,
|
||||
.acl-policy-custom-controls {
|
||||
grid-template-columns: 1fr
|
||||
}
|
||||
|
||||
.acl-policy-mode-custom {
|
||||
grid-column: auto
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width:640px) {
|
||||
.acl-policy-step-head,
|
||||
.acl-policy-preview-head {
|
||||
align-items: stretch;
|
||||
flex-direction: column
|
||||
}
|
||||
|
||||
.acl-policy-switch,
|
||||
.acl-policy-step-head>button {
|
||||
width: 100%
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user