merge newui branch

This commit is contained in:
Gani Georgiev
2026-04-18 16:29:34 +03:00
parent 58f605e90c
commit 4c44044c0c
804 changed files with 58660 additions and 56663 deletions
@@ -0,0 +1,121 @@
export function batchAccordion(pageData) {
return t.details(
{
pbEvent: "batchApiAccordion",
className: "accordion batch-api-accordion",
name: "settingsAccordion",
},
t.summary(
null,
t.i({ className: "ri-archive-stack-line" }),
t.span({ className: "txt" }, "Batch API"),
t.div({ className: "flex-fill" }),
() => {
if (pageData.formSettings.batch.enabled) {
return t.span({ className: "label success" }, "Enabled");
}
return t.span({ className: "label" }, "Disabled");
},
() => {
if (!app.utils.isEmpty(app.store.errors?.batch)) {
return t.i({
className: "ri-error-warning-fill txt-danger",
ariaDescription: app.attrs.tooltip("Has errors", "left"),
});
}
},
),
t.div(
{ className: "grid sm" },
t.div(
{ className: "col-lg-12" },
t.div(
{ className: "field" },
t.input({
id: "batch.enabled",
name: "batch.enabled",
type: "checkbox",
className: "switch",
checked: () => pageData.formSettings.batch.enabled || false,
onchange: (e) => (pageData.formSettings.batch.enabled = e.target.checked),
}),
t.label(
{ htmlFor: "batch.enabled" },
t.span({ className: "txt" }, "Enable"),
t.small({ className: "txt-hint" }, " (experimental)"),
),
),
),
t.div(
{ className: "col-lg-4" },
t.div(
{ className: "field" },
t.label(
{ htmlFor: "batch.maxRequests" },
t.span({ className: "txt" }, "Max requests in a batch"),
t.i({
className: "ri-information-line link-faded",
ariaDescription: app.attrs.tooltip(
"Rate limiting (if enabled) also applies for the batch create/update/upsert/delete requests.",
"right",
),
}),
),
t.input({
id: "batch.maxRequests",
name: "batch.maxRequests",
type: "number",
min: 1,
step: 1,
required: () => pageData.formSettings.batch.enabled,
disabled: () => !pageData.formSettings.batch.enabled,
value: () => pageData.formSettings.batch.maxRequests,
oninput: (e) => (pageData.formSettings.batch.maxRequests = e.target.value << 0),
}),
),
),
t.div(
{ className: "col-lg-4" },
t.div(
{ className: "field" },
t.label(
{ htmlFor: "batch.timeout" },
t.span({ className: "txt" }, "Max processing time (in seconds)"),
),
t.input({
id: "batch.timeout",
name: "batch.timeout",
type: "number",
min: 1,
step: 1,
required: () => pageData.formSettings.batch.enabled,
disabled: () => !pageData.formSettings.batch.enabled,
value: () => pageData.formSettings.batch.timeout,
oninput: (e) => pageData.formSettings.batch.timeout = parseInt(e.target.value, 10),
}),
),
),
t.div(
{ className: "col-lg-4" },
t.div(
{ className: "field" },
t.label(
{ htmlFor: "batch.maxBodySize" },
t.span({ className: "txt" }, "Max body size (in bytes)"),
),
t.input({
id: "batch.maxBodySize",
name: "batch.maxBodySize",
type: "number",
min: 0,
step: 1,
placeholder: "Default to 128MB",
disabled: () => !pageData.formSettings.batch.enabled,
value: () => pageData.formSettings.batch.maxBodySize || "",
oninput: (e) => pageData.formSettings.batch.maxBodySize = parseInt(e.target.value, 10),
}),
),
),
),
);
}
@@ -0,0 +1,287 @@
import { settingsSidebar } from "../settingsSidebar";
import { batchAccordion } from "./batchAccordion";
import { rateLimitAccordion, sortRules } from "./rateLimitAccordion";
import { trustedProxyAccordion } from "./trustedProxyAccordion";
export function pageApplicationSettings() {
app.store.title = "Application settings";
const data = store({
isLoading: false,
isSaving: false,
formSettings: null,
originalFormSettings: null,
get originalFormSettingsHash() {
return JSON.stringify(data.originalFormSettings);
},
get hasChanges() {
return data.originalFormSettingsHash != JSON.stringify(data.formSettings);
},
});
loadSettings();
async function loadSettings() {
data.isLoading = true;
try {
const settings = await app.pb.settings.getAll();
init(settings);
data.isLoading = false;
} catch (err) {
if (!err.isAbort) {
app.checkApiError(err);
// data.isLoading = false; don't reset in case of a server error
}
}
}
async function save() {
if (data.isSaving || !data.hasChanges) {
return;
}
data.isSaving = true;
data.formSettings.rateLimits.rules = sortRules(data.formSettings.rateLimits.rules);
try {
const redacted = app.utils.filterRedactedProps(data.formSettings);
const settings = await app.pb.settings.update(redacted);
init(settings);
app.toasts.success("Successfully saved application settings.");
} catch (err) {
app.checkApiError(err);
}
data.isSaving = false;
}
function init(settings = {}) {
// refresh local app settings
app.store.settings = JSON.parse(JSON.stringify(settings));
// load from the css style as fallback
if (!settings.meta?.accentColor) {
const cssColor = window.getComputedStyle(document.documentElement)?.getPropertyValue("--accentColor");
if (cssColor?.startsWith("#")) {
settings.meta = settings.meta || {};
settings.meta.accentColor = cssColor.toLowerCase() || "";
}
}
data.originalFormSettings = {
meta: settings.meta || {},
batch: settings.batch || {},
trustedProxy: settings.trustedProxy || { headers: [] },
rateLimits: settings.rateLimits || { rules: [] },
};
sortRules(data.originalFormSettings.rateLimits.rules);
data.formSettings = JSON.parse(JSON.stringify(data.originalFormSettings));
}
function reset() {
data.formSettings = JSON.parse(data.originalFormSettingsHash);
}
return t.div(
{
pbEvent: "pageApplicationSettings",
className: "page page-application-settings",
},
settingsSidebar(),
t.div(
{ className: "page-content full-height" },
t.header(
{ className: "page-header" },
t.nav(
{ className: "breadcrumbs" },
t.div({ className: "breadcrumb-item" }, "Settings"),
t.div({ className: "breadcrumb-item" }, "Application"),
),
),
t.div(
{ className: "wrapper m-b-base" },
() => {
if (data.isLoading) {
return t.div({ className: "block txt-center" }, t.span({ className: "loader lg" }));
}
return t.form(
{
pbEvent: "applicationSettingsForm",
className: "grid application-settings-form",
inert: () => data.isSaving,
onsubmit: (e) => {
e.preventDefault();
save();
},
},
t.div(
{ className: "col-md-5" },
t.div(
{ className: "field" },
t.label({ htmlFor: "meta.appName" }, "Application name"),
t.input({
id: "meta.appName",
name: "meta.appName",
type: "text",
required: true,
value: () => data.formSettings.meta.appName || "",
oninput: (e) => (data.formSettings.meta.appName = e.target.value),
}),
),
),
t.div(
{ className: "col-md-5" },
t.div(
{ className: "field" },
t.label({ htmlFor: "meta.appURL" }, "Application URL"),
t.input({
id: "meta.appURL",
name: "meta.appURL",
type: "url",
required: true,
value: () => data.formSettings.meta.appURL || "",
oninput: (e) => (data.formSettings.meta.appURL = e.target.value),
}),
),
),
t.div(
{ className: "col-md-2" },
// pass isSaving to ensure that it will be rerendered after save
() => accentColorField(data, data.isSaving),
),
t.div(
{ className: "col-lg-12" },
() => trustedProxyAccordion(data),
() => rateLimitAccordion(data),
() => batchAccordion(data),
),
t.div(
{ className: "col-lg-12" },
t.div(
{ className: "field" },
t.input({
id: "meta.hideControls",
name: "meta.hideControls",
type: "checkbox",
className: "switch",
checked: () => data.formSettings.meta.hideControls,
onchange: (e) => (data.formSettings.meta.hideControls = e.target.checked),
}),
t.label(
{ htmlFor: "meta.hideControls" },
t.span({ className: "txt" }, "Hide collection create and edit controls"),
),
),
),
t.div({ className: "col-lg-12" }, t.hr()),
t.div(
{ className: "col-lg-12" },
t.div(
{ className: "flex" },
t.div({ className: "m-r-auto" }),
t.button(
{
type: "button",
className: "btn transparent secondary",
disabled: () => data.isSaving,
hidden: () => !data.hasChanges,
onclick: reset,
},
t.span({ className: "txt" }, "Cancel"),
),
t.button(
{
className: () => `btn expanded ${data.isSaving ? "loading" : ""}`,
disabled: () => !data.hasChanges || data.isSaving,
},
t.span({ className: "txt" }, "Save changes"),
),
),
),
);
},
),
t.footer({ className: "page-footer" }, app.components.credits()),
),
);
}
function accentColorField(pageData) {
const uniqueId = "accent_" + app.utils.randomString();
const local = store({
isTooLight: false,
});
let colorChangeTimeoutId;
let tempNoAnimationTimeoutId;
function changeAccentColor(color) {
// temporary disable animations to minimize flickering
clearTimeout(tempNoAnimationTimeoutId);
document.documentElement.style.setProperty("--animationSpeed", "0");
if (color) {
document.documentElement.style.setProperty("--accentColor", color.toLowerCase());
} else {
document.documentElement.style.removeProperty("--accentColor");
}
// restore animation
tempNoAnimationTimeoutId = setTimeout(() => {
document.documentElement.style.removeProperty("--animationSpeed");
}, 100);
}
const watchers = [
watch(() => pageData.formSettings?.meta?.accentColor, (newColor) => {
clearTimeout(colorChangeTimeoutId);
colorChangeTimeoutId = setTimeout(() => {
changeAccentColor(newColor);
}, 100);
}),
];
return t.div(
{
className: "field",
ariaDescription: app.attrs.tooltip(() => local.isTooLight ? "Invalid - color is too light" : ""),
onunmount: () => {
clearTimeout(colorChangeTimeoutId);
changeAccentColor(pageData.formSettings.meta.accentColor);
watchers.forEach((w) => w?.unwatch());
},
},
t.label(
{ htmlFor: uniqueId },
t.span({ className: "txt" }, "Accent"),
t.i({
hidden: () => !local.isTooLight,
className: "txt-warning ri-alert-line",
}),
),
app.components.colorPicker({
id: uniqueId,
name: "meta.accentColor",
predefinedColors: () => app.store.predefinedAccentColors,
value: () => pageData.formSettings.meta.accentColor,
onchange: (color) => {
// @todo consider removing the constraint once contrast-color is implemented
local.isTooLight = false;
if (!app.utils.isDarkEnoughForWhiteText(color)) {
local.isTooLight = true;
return;
}
pageData.formSettings.meta.accentColor = color;
},
}),
);
}
@@ -0,0 +1,415 @@
import { basePredefinedTags, openRateLimitInfoModal } from "./rateLimitInfoModal";
// sort the specified rules list in place
export function sortRules(rules) {
if (!rules) {
return;
}
let compare = [{}, {}];
rules.sort((a, b) => {
compare[0].length = a.label.length;
compare[0].isTag = a.label.includes(":") || !a.label.includes("/");
compare[0].isWildcardTag = compare[0].isTag && a.label.startsWith("*");
compare[0].isExactTag = compare[0].isTag && !compare[0].isWildcardTag;
compare[0].isPrefix = !compare[0].isTag && a.label.endsWith("/");
compare[0].hasMethod = !compare[0].isTag && a.label.includes(" /");
compare[1].length = b.label.length;
compare[1].isTag = b.label.includes(":") || !b.label.includes("/");
compare[1].isWildcardTag = compare[1].isTag && b.label.startsWith("*");
compare[1].isExactTag = compare[1].isTag && !compare[1].isWildcardTag;
compare[1].isPrefix = !compare[1].isTag && b.label.endsWith("/");
compare[1].hasMethod = !compare[1].isTag && b.label.includes(" /");
for (let item of compare) {
item.priority = 0; // reset
if (item.isTag) {
item.priority += 1000;
if (item.isExactTag) {
item.priority += 10;
} else {
item.priority += 5;
}
} else {
if (item.hasMethod) {
item.priority += 10;
}
if (!item.isPrefix) {
item.priority += 5;
}
}
}
// sort additionally prefix paths based on their length
if (
compare[0].isPrefix
&& compare[1].isPrefix
&& ((compare[0].hasMethod && compare[1].hasMethod) || (!compare[0].hasMethod && !compare[1].hasMethod))
) {
if (compare[0].length > compare[1].length) {
compare[0].priority += 1;
} else if (compare[0].length < compare[1].length) {
compare[1].priority += 1;
}
}
if (compare[0].priority > compare[1].priority) {
return -1;
}
if (compare[0].priority < compare[1].priority) {
return 1;
}
return 0;
});
return rules;
}
export function rateLimitAccordion(pageData) {
const audienceOptions = [
{ value: "", label: "All" },
{ value: "@guest", label: "Guest only" },
{ value: "@auth", label: "Auth only" },
];
const accordionData = store({
predefinedTags: basePredefinedTags,
});
loadPredefinedTags();
async function loadPredefinedTags() {
let collections = [];
// fetch an up-to-date collections list
try {
collections = await app.pb.collections.getFullList();
} catch (err) {
console.warn("loadPredefinedTags: failed to load collections", err);
return;
}
accordionData.predefinedTags = [];
for (const collection of collections) {
if (collection.system) {
continue;
}
accordionData.predefinedTags.push({ value: collection.name + ":list" });
accordionData.predefinedTags.push({ value: collection.name + ":view" });
if (collection.type != "view") {
accordionData.predefinedTags.push({ value: collection.name + ":create" });
accordionData.predefinedTags.push({ value: collection.name + ":update" });
accordionData.predefinedTags.push({ value: collection.name + ":delete" });
}
if (collection.type == "auth") {
accordionData.predefinedTags.push({
value: collection.name + ":listAuthMethods",
});
accordionData.predefinedTags.push({
value: collection.name + ":authRefresh",
});
accordionData.predefinedTags.push({ value: collection.name + ":auth" });
accordionData.predefinedTags.push({
value: collection.name + ":authWithPassword",
});
accordionData.predefinedTags.push({
value: collection.name + ":authWithOAuth2",
});
accordionData.predefinedTags.push({
value: collection.name + ":authWithOTP",
});
accordionData.predefinedTags.push({
value: collection.name + ":requestOTP",
});
accordionData.predefinedTags.push({
value: collection.name + ":requestPasswordReset",
});
accordionData.predefinedTags.push({
value: collection.name + ":confirmPasswordReset",
});
accordionData.predefinedTags.push({
value: collection.name + ":requestVerification",
});
accordionData.predefinedTags.push({
value: collection.name + ":confirmVerification",
});
accordionData.predefinedTags.push({
value: collection.name + ":requestEmailChange",
});
accordionData.predefinedTags.push({
value: collection.name + ":confirmEmailChange",
});
}
if (collection.fields.find((f) => f.type == "file")) {
accordionData.predefinedTags.push({ value: collection.name + ":file" });
}
}
accordionData.predefinedTags = accordionData.predefinedTags.concat(basePredefinedTags);
}
function newRule() {
if (!Array.isArray(pageData.formSettings.rateLimits.rules)) {
pageData.formSettings.rateLimits.rules = [];
}
pageData.formSettings.rateLimits.rules.push({
label: "",
maxRequests: 200,
duration: 3,
audience: "",
});
// enable the rate limiter if this is the first rule that is being added
if (pageData.formSettings.rateLimits.rules.length == 1) {
pageData.formSettings.rateLimits.enabled = true;
}
}
function removeRule(i) {
pageData.formSettings.rateLimits.rules.splice(i, 1);
if (!pageData.formSettings.rateLimits.rules.length) {
pageData.formSettings.rateLimits.enabled = false;
}
}
const watchers = [];
return t.details(
{
pbEvent: "rateLimitAccordion",
className: "accordion rate-limit-accordion",
name: "settingsAccordion",
onmount: () => {
watchers.push(
// clear rules errors on any rule change since an error could be
// for a duplicated tag that may have been updated in a different rule
watch(
() => JSON.stringify(pageData.formSettings.rateLimits.rules),
() => {
if (!app.store.errors?.rateLimits?.rules) {
return;
}
delete app.store.errors.rateLimits;
},
),
);
},
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
t.summary(
null,
t.i({ className: "ri-pulse-fill" }),
t.span({ className: "txt" }, "Rate limiting"),
t.div({ className: "flex-fill" }),
() => {
if (pageData.formSettings.rateLimits.enabled) {
return t.span({ className: "label success" }, "Enabled");
}
return t.span({ className: "label" }, "Disabled");
},
() => {
if (!app.utils.isEmpty(app.store.errors?.rateLimits)) {
return t.i({
className: "ri-error-warning-fill txt-danger",
ariaDescription: app.attrs.tooltip("Has errors", "left"),
});
}
},
),
t.div(
{ className: "grid sm" },
t.div(
{ className: "col-lg-12" },
t.div(
{ className: "field" },
t.input({
id: "rateLimits.enabled",
name: "rateLimits.enabled",
type: "checkbox",
className: "switch",
checked: () => pageData.formSettings.rateLimits.enabled || false,
onchange: (e) => (pageData.formSettings.rateLimits.enabled = e.target.checked),
}),
t.label(
{ htmlFor: "rateLimits.enabled" },
t.span({ className: "txt" }, "Enable"),
t.small({ className: "txt-hint" }, " (experimental)"),
),
),
),
t.div(
{ className: "col-lg-12" },
t.div(
{ className: "rate-limit-table-wrapper" },
t.table(
{ className: "rate-limit-table" },
t.thead(
{
hidden: () => !pageData.formSettings.rateLimits.rules?.length,
},
t.tr(
null,
t.th({ className: "col-label" }, "Rate limit label"),
t.th(
{ className: "col-requests" },
"Max requests",
t.br(),
t.small(null, "(per IP)"),
),
t.th(
{ className: "col-duration" },
"Interval",
t.br(),
t.small(null, "(in seconds)"),
),
t.th({ className: "col-audience" }, "Targeted users"),
t.th({ className: "col-action" }),
),
),
t.tbody(null, () => {
const rows = [];
const rules = pageData.formSettings.rateLimits.rules || [];
for (let i = 0; i < rules.length; i++) {
const rule = rules[i];
rows.push(
t.tr(
{ className: "rate-limit-row" },
t.td(
{ className: "col-label" },
t.div(
{ className: "field" },
t.input({
type: "text",
required: true,
className: "inline-error",
id: "rateLimits.rules." + i + ".label",
name: "rateLimits.rules." + i + ".label",
placeholder: "tag (users:create) or path (/api/)",
"html-list": "rateLimits.rules." + i + ".label_list",
value: () => rule.label,
oninput: (e) => (rule.label = e.target.value),
}),
t.datalist(
{
id: "rateLimits.rules." + i + ".label_list",
},
() => {
return accordionData.predefinedTags.map((tag) => {
return t.option({ value: tag.value }, tag.label || "");
});
},
),
),
),
t.td(
{ className: "col-requests" },
t.div(
{ className: "field" },
t.input({
type: "number",
required: true,
placeholder: "Max requests*",
className: "inline-error",
min: 1,
step: 1,
name: "rateLimits.rules." + i + ".maxRequests",
value: () => rule.maxRequests || 0,
oninput: (e) => rule.maxRequests = parseInt(e.target.value, 10),
}),
),
),
t.td(
{ className: "col-duration" },
t.div(
{ className: "field" },
t.input({
type: "number",
required: true,
placeholder: "Interval*",
className: "inline-error",
min: 1,
step: 1,
name: "rateLimits.rules." + i + ".duration",
value: () => rule.duration,
oninput: (e) => rule.duration = parseInt(e.target.value, 10),
}),
),
),
t.td(
{ className: "col-audience" },
t.div(
{ className: "field" },
app.components.select({
name: "rateLimits.rules." + i + ".audience",
className: "inline-error",
options: audienceOptions,
required: true,
value: () => rule.audience || "",
onchange: (selected) => {
rule.audience = selected?.[0]?.value;
},
}),
),
),
t.td(
{ className: "col-action" },
t.button(
{
type: "button",
araiaDescription: app.attrs.tooltip("Remove rule"),
className: "btn sm secondary transparent circle",
onclick: () => removeRule(i),
},
t.i({ className: "ri-close-line" }),
),
),
),
);
}
return rows;
}),
),
),
t.div(
{ className: "flex m-t-sm" },
t.button(
{
type: "button",
className: "btn secondary sm",
onclick: () => newRule(),
},
t.i({ className: "ri-add-line" }),
t.span({ className: "txt" }, "Add rate limit rule"),
),
t.button(
{
type: "button",
className: "link-hint txt-sm m-l-auto",
onclick: () => openRateLimitInfoModal(),
},
t.em(null, "Learn more about the rate limit rules"),
),
),
),
),
);
}
@@ -0,0 +1,152 @@
export let basePredefinedTags = [
{ value: "*:list" },
{ value: "*:view" },
{ value: "*:create" },
{ value: "*:update" },
{ value: "*:delete" },
{ value: "*:file", description: "targets the files download endpoint" },
{ value: "*:listAuthMethods" },
{ value: "*:authRefresh" },
{ value: "*:auth", description: "targets all auth methods" },
{ value: "*:authWithPassword" },
{ value: "*:authWithOAuth2" },
{ value: "*:authWithOTP" },
{ value: "*:requestOTP" },
{ value: "*:requestPasswordReset" },
{ value: "*:confirmPasswordReset" },
{ value: "*:requestVerification" },
{ value: "*:confirmVerification" },
{ value: "*:requestEmailChange" },
{ value: "*:confirmEmailChange" },
];
export function openRateLimitInfoModal() {
const modal = rateLimitInfoModal();
document.body.appendChild(modal);
app.modals.open(modal);
}
function rateLimitInfoModal() {
return t.div(
{
pbEvent: "rateLimitInfoModal",
className: "modal rate-limit-info-modal",
onafterclose: (el) => {
el?.remove();
},
},
t.header({ className: "modal-header" }, t.h5(null, "Rate limit label format")),
t.div(
{ className: "modal-content" },
t.p(null, "The rate limit rules are resolved in the following order (stops on the first match):"),
t.ol(
null,
t.li(null, "exact tag (e.g. ", t.code(null, "users:create")),
t.li(null, "wildcard tag (e.g. ", t.code(null, "*:create")),
t.li(null, "METHOD + exact path (e.g. ", t.code(null, "POST /a/b")),
t.li(null, "METHOD + prefix path (e.g. ", t.code(null, "POST /a/b", t.strong(null, "/"))),
t.li(null, "exact path (e.g. ", t.code(null, "/a/b")),
t.li(null, "prefix path (e.g. ", t.code(null, "/a/b", t.strong(null, "/"))),
),
t.p(
null,
`In case of multiple rules with the same label but different target user audience (e.g. "guest" vs "auth"), only the matching audience rule is taken in consideration.`,
),
t.hr(),
t.p(null, "The rate limit label could be in one of the following formats:"),
t.ul(
null,
t.li(
{ className: "m-b-sm" },
t.code(null, "[METHOD ]/my/path"),
" - full exact route match (",
t.strong(null, "must be without trailing slash"),
"; \"METHOD\" is optional).",
t.br(),
"For example:",
t.ul(
{ className: "m-0" },
t.li(
null,
t.code(null, "/hello"),
" - matches ",
t.code(null, "GET /hello"),
", ",
t.code(null, "POST /hello"),
", etc.",
),
t.li(null, t.code(null, "POST /hello"), " - matches only ", t.code(null, "POST /hello")),
),
),
t.li(
{ className: "m-b-sm" },
t.code(null, "[METHOD ]/my/prefix", t.strong(null, "/")),
" - path prefix (",
t.strong(null, "must end with trailing slash;"),
"\"METHOD\" is optional). For example:",
t.ul(
{ className: "m-0" },
t.li(
null,
t.code(null, "/hello/"),
" - matches ",
t.code(null, "GET /hello"),
", ",
t.code(null, "POST /hello/a/b/c"),
", etc.",
),
t.li(
null,
t.code(null, "POST /hello/"),
" - matches ",
t.code(null, "POST /hello"),
", ",
t.code(null, "POST /hello/a/b/c"),
", etc.",
),
),
),
t.li(
{ className: "m-b-0" },
t.code(null, "collectionName:predefinedTag"),
" - targets a specific action of a single collection.",
" To apply the rule for all collections you can use the ",
t.code(null, "*"),
" wildcard. For example:",
t.code(null, "posts:create"),
", ",
t.code(null, "users:listAuthMethods"),
", ",
t.code(null, "*:auth"),
".",
t.br(),
"The predifined collection tags are (",
t.em(null, "there should be autocomplete once you start typing"),
"):",
t.ul({ className: "m-0" }, () => {
return basePredefinedTags.map((tag) => {
return t.li(null, tag.value.replace("*:", ":"), () => {
if (tag.description) {
return t.em({ className: "txt-hint" }, " (", tag.description, ")");
}
});
});
}),
),
),
),
t.footer(
{ className: "modal-footer" },
t.button(
{
type: "button",
className: "btn transparent m-r-auto",
onclick: () => app.modals.close(),
},
t.span({ className: "txt" }, "Close"),
),
),
);
}
@@ -0,0 +1,240 @@
export function trustedProxyAccordion(pageData) {
const commonProxyHeaders = ["X-Forwarded-For", "Fly-Client-IP", "CF-Connecting-IP"];
const ipOptions = [
{ label: "Use leftmost IP", value: true },
{ label: "Use rightmost IP", value: false },
];
const proxyInfo = store({
isLoading: false,
realIP: "",
possibleProxyHeader: "",
get suggestedProxyHeaders() {
if (!proxyInfo.possibleProxyHeader) {
return commonProxyHeaders;
}
return [proxyInfo.possibleProxyHeader].concat(
commonProxyHeaders.filter((h) => h != proxyInfo.possibleProxyHeader),
);
},
get isEnabled() {
return !app.utils.isEmpty(pageData.formSettings.trustedProxy?.headers);
},
});
loadProxyInfo();
async function loadProxyInfo() {
proxyInfo.isLoading = true;
try {
const health = await app.pb.health.check({ requestKey: "loadProxyInfo" });
proxyInfo.realIP = health.data?.realIP || "";
proxyInfo.possibleProxyHeader = health.data?.possibleProxyHeader || "";
proxyInfo.isLoading = false;
} catch (err) {
if (!err.isAbort) {
app.checkApiError(err);
proxyInfo.isLoading = false;
}
}
}
return t.details(
{
pbEvent: "trustedProxyAccordion",
className: "accordion trusted-proxy-accordion",
name: "settingsAccordion",
open: () => (proxyInfo.isLoading ? false : null),
},
t.summary(
null,
t.i({ className: "ri-route-line" }),
t.span({ className: "txt" }, "User IP proxy headers"),
() => {
if (proxyInfo.isLoading) {
return t.span({ className: "loader sm" });
}
if (!proxyInfo.isEnabled && proxyInfo.possibleProxyHeader) {
return t.i({
className: "ri-alert-line txt-warning",
ariaDescription: app.attrs.tooltip(
"Detected proxy header.\nIt is recommend to list it as trusted.",
"right",
),
});
}
if (
proxyInfo.isEnabled
&& proxyInfo.possibleProxyHeader
&& !pageData.formSettings.trustedProxy.headers.includes(proxyInfo.possibleProxyHeader)
) {
return t.i({
className: "ri-alert-line txt-hint",
ariaDescription: app.attrs.tooltip(
"The configured proxy header doesn't match with the detected one.",
"right",
),
});
}
},
t.div({ className: "flex-fill" }),
() => {
if (proxyInfo.isEnabled) {
return t.span({ className: "label success" }, "Enabled");
}
return t.span({ className: "label" }, "Disabled");
},
() => {
if (!app.utils.isEmpty(app.store.errors?.trustedProxy)) {
return t.i({
className: "ri-error-warning-fill txt-danger",
ariaDescription: app.attrs.tooltip("Has errors", "left"),
});
}
},
),
t.p(
{ className: "m-t-0" },
"Below you should see your real IP. If not - configure the correct proxy header for your environment.",
),
t.div(
{
hidden: () => proxyInfo.isLoading,
className: "alert info m-b-sm",
},
t.div(
{ className: "flex gap-5" },
t.span(null, "Resolved user IP:"),
t.strong(null, () => proxyInfo.realIP || "N/A"),
),
t.div(
{ className: "flex gap-5" },
t.span(null, "Detected proxy header:"),
t.strong(null, () => proxyInfo.possibleProxyHeader || "N/A"),
),
),
t.div(
{ className: "content m-b-sm" },
t.p(
null,
`
When PocketBase is deployed on platforms like Fly or it is accessible through proxies such as
NGINX, requests from different users will originate from the same IP address (the IP of the proxy
connecting to your PocketBase app).
`,
),
t.p(
null,
`
In this case to retrieve the actual user IP (used for rate limiting, logging, etc.) you need to
properly configure your proxy and list below the trusted headers that PocketBase could use to
extract the user IP.
`,
),
t.p({ className: "txt-bold" }, `When using such proxy, to avoid spoofing it is recommended to:`),
t.ul(
{ className: "txt-bold" },
t.li(
null,
"use headers that are controlled only by the proxy and cannot be manually set by the users",
),
t.li(null, "make sure that the PocketBase server can be accessed ONLY through the proxy"),
),
t.p(null, "You can clear the headers field if PocketBase is not deployed behind a proxy."),
),
t.div(
{ className: "grid sm" },
t.div(
{ className: "col-lg-9" },
t.div(
{ className: "fields" },
t.div(
{ className: "field" },
t.label({ htmlFor: "trustedProxy.headers" }, "Trusted IP proxy headers"),
t.input({
type: "text",
id: "trustedProxy.headers",
name: "trustedProxy.headers",
placeholder: "Leave empty to disable",
value: () => app.utils.joinNonEmpty(pageData.formSettings.trustedProxy.headers),
oninput: (e) => {
const newValue = app.utils.splitNonEmpty(e.target.value, ",");
const newStr = app.utils.joinNonEmpty(newValue);
const oldStr = app.utils.joinNonEmpty(pageData.formSettings.trustedProxy.headers);
// has an actual change
if (oldStr != newStr) {
pageData.formSettings.trustedProxy.headers = newValue;
}
},
}),
),
t.div(
{ className: "field addon" },
t.button(
{
type: "button",
className: () =>
`btn sm secondary transparent ${
app.utils.isEmpty(pageData.formSettings.trustedProxy.headers) ? "hidden" : ""
}`,
onclick: () => {
pageData.formSettings.trustedProxy.headers = [];
},
},
t.span({ className: "txt" }, "Clear"),
),
),
),
t.div(
{ className: "field-help" },
"Comma separated list of headers such as: ",
t.div({ className: "inline-flex gap-5" }, () => {
return proxyInfo.suggestedProxyHeaders.map((header) => {
return t.div({
type: "button",
className: "label sm link-hint",
onclick: () => {
pageData.formSettings.trustedProxy.headers = [header];
},
textContent: header,
});
});
}),
),
),
t.div(
{ className: "col-lg-3" },
t.div(
{ className: "field" },
t.label(
{ htmlFor: "trustedProxy.useLeftmostIP" },
t.span({ className: "txt" }, "IP priority"),
t.i({
className: "ri-information-line tooltip-right",
ariaDescription: app.attrs.tooltip(
"This is in case the proxy returns more than 1 IP as header value. The rightmost IP is usually considered to be the more trustworthy but this could vary depending on the proxy.",
),
}),
),
app.components.select({
id: "trustedProxy.useLeftmostIP",
name: "trustedProxy.useLeftmostIP",
options: ipOptions,
required: true,
value: () => pageData.formSettings.trustedProxy.useLeftmostIP || false,
onchange: (selected) => {
pageData.formSettings.trustedProxy.useLeftmostIP = selected?.[0]?.value;
},
}),
),
),
),
);
}