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

View File

@@ -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),
}),
),
),
),
);
}

View File

@@ -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;
},
}),
);
}

View File

@@ -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"),
),
),
),
),
);
}

View File

@@ -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"),
),
),
);
}

View File

@@ -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;
},
}),
),
),
),
);
}

View File

@@ -0,0 +1,151 @@
export function openBackupCreateModal(settings = {
oncreated: null,
}) {
const modal = backupCreateModal(settings);
if (!modal) {
return;
}
document.body.appendChild(modal);
app.modals.open(modal);
}
function backupCreateModal(settings) {
let modal;
const uniqueId = "backup_create_" + app.utils.randomString();
const data = store({
name: "",
isSubmitting: false,
});
let submitTimeoutId;
async function submit() {
if (data.isSubmitting) {
return;
}
data.isSubmitting = true;
clearTimeout(submitTimeoutId);
submitTimeoutId = setTimeout(() => {
app.modals.close(modal);
}, 1500);
try {
await app.pb.backups.create(data.name, { requestKey: uniqueId });
data.isSubmitting = false;
if (settings.oncreated) {
settings.oncreated(data.name);
}
app.toasts.success("Successfully generated new backup.");
app.modals.close(modal);
} catch (err) {
if (!err.isAbort) {
clearTimeout(submitTimeoutId);
data.isSubmitting = false;
app.checkApiError(err);
}
}
}
modal = t.div(
{
pbEvent: "backupCreateModal",
className: "modal popup backup-create-modal",
onbeforeclose: () => {
if (data.isSubmitting) {
app.toasts.info(
"The backup was started but may take a while to complete. You can come back later.",
);
}
},
onafterclose: (el) => {
clearTimeout(submitTimeoutId);
el?.remove();
},
},
t.header(
{ className: "modal-header" },
t.h5({ className: "m-auto txt-center" }, "Initialize new backup"),
),
t.form(
{
id: uniqueId,
className: "modal-content backup-restore-form",
autocomplete: "off",
onsubmit: (e) => {
e.preventDefault();
submit();
},
},
t.div(
{ className: "grid" },
t.div(
{ className: "col-lg-12" },
t.div(
{ className: "alert warning" },
t.div(
{ className: "content" },
t.p(
null,
`Please note that during the backup other concurrent write requests may fail since the database will be temporary "locked" (this usually happens only during the ZIP generation).`,
),
t.p(
{ className: "txt-bold" },
`If you are using S3 storage for the collections file upload, you'll have to backup them separately since they are not locally stored and they will not be included in the generated backup!`,
),
),
),
),
t.div(
{ className: "col-lg-12" },
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + "_name" }, "Backup name"),
t.input({
id: uniqueId + "_name",
name: "name",
type: "text",
pattern: "^[a-z0-9_-]+\.zip$",
placeholder: "Leave empty to autogenerate",
value: () => data.name,
oninput: (e) => (data.name = e.target.value),
}),
),
t.div({ className: "field-help" }, "Must be in the format [a-z0-9_-].zip"),
),
),
),
t.footer(
{ className: "modal-footer" },
t.button(
{
type: "button",
className: "btn transparent m-r-auto",
disabled: () => data.isSubmitting,
onclick: () => app.modals.close(modal),
},
t.span({ className: "txt" }, "Cancel"),
),
t.button(
{
"html-form": uniqueId,
type: "submit",
className: () => `btn ${data.isSubmitting ? "loading" : ""}`,
disabled: () => data.isSubmitting,
},
t.span({ className: "txt" }, "Start backup"),
),
),
);
return modal;
}

View File

@@ -0,0 +1,181 @@
export function openBackupRestoreModal(key) {
const modal = backupRestoreModal(key);
document.body.appendChild(modal);
app.modals.open(modal);
}
function backupRestoreModal(key) {
const uniqueId = "backup_restore_" + app.utils.randomString();
const data = store({
key: key,
keyConfirm: "",
isSubmitting: false,
get canSubmit() {
return data.key && data.key == data.keyConfirm;
},
});
let reloadTimeoutId;
async function submit() {
if (data.isSubmitting || !data.canSubmit) {
return;
}
clearTimeout(reloadTimeoutId);
data.isSubmitting = true;
try {
await app.pb.backups.restore(data.keyConfirm);
// optimistic restore page reload
reloadTimeoutId = setTimeout(() => {
window.location.reload();
data.isSubmitting = false;
}, 2000);
} catch (err) {
clearTimeout(reloadTimeoutId);
if (!err?.isAbort) {
data.isSubmitting = false;
app.checkApiError(err);
}
}
}
return t.div(
{
pbEvent: "backupRestoreModal",
className: "modal popup backup-restore-modal",
onbeforeclose: () => {
return !data.isSubmitting;
},
onafterclose: (el) => {
el?.remove();
},
onunmount: () => {
clearTimeout(reloadTimeoutId);
},
},
t.header(
{ className: "modal-header" },
t.h5(
{ className: "m-auto txt-center" },
"Restore ",
t.strong(null, () => data.key),
),
),
t.form(
{
id: uniqueId,
className: "modal-content backup-restore-form",
autocomplete: "off",
onsubmit: (e) => {
e.preventDefault();
submit();
},
},
t.div(
{ className: "grid" },
t.div(
{ className: "col-lg-12" },
t.div(
{ className: "alert danger" },
t.div(
{ className: "content" },
t.p(
{ className: "txt-bold" },
"Please proceed with extreme caution and use it only with trusted backups!",
),
t.p(null, "Backup restore currently works only on UNIX based systems."),
t.p(
null,
"The restore operation will attempt to replace your existing ",
t.code(null, "pb_data"),
" with the one from the backup and will restart the application process.",
),
t.p(
null,
"This means that on success all of your data (including app settings, users, superusers, etc.) will be replaced with the ones from the backup.",
),
t.p(
null,
"The operation will be reverted if the backup is invalid (ex. missing ",
t.code(null, "data.db"),
" file).",
),
t.p(null, "Below is an oversimplified version of the restore flow:"),
t.ol(
null,
t.li(
null,
"Replaces the current ",
t.code(null, "pb_data"),
" with the content from the backup.",
),
t.li(null, "Triggers app restart."),
t.li(
null,
"Applies all migrations that are missing in the restored ",
t.code(null, "pb_data"),
".",
),
t.li(null, "Initializes the app server as usual."),
),
),
),
),
t.div(
{ className: "col-lg-12" },
t.div(
{ className: "confirm-key-label m-b-sm" },
"Type the backup name ",
t.div(
{ className: "label" },
() => data.key,
app.components.copyButton(() => data.key),
),
" to confirm:",
),
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + "_key" }, "Backup name"),
t.input({
id: uniqueId + "_key",
name: "key",
type: "text",
required: true,
value: () => data.keyConfirm,
oninput: (e) => (data.keyConfirm = e.target.value),
}),
),
),
),
),
t.footer(
{ className: "modal-footer" },
t.button(
{
type: "button",
className: "btn transparent m-r-auto",
onclick: () => app.modals.close(),
disabled: () => data.isSubmitting,
},
t.span({ className: "txt" }, "Cancel"),
),
t.button(
{
"html-form": uniqueId,
type: "submit",
className: () => `btn ${data.isSubmitting ? "loading" : ""}`,
disabled: () => data.isSubmitting || !data.canSubmit,
},
t.span({ className: "txt" }, "Restore backup"),
),
),
);
}

View File

@@ -0,0 +1,89 @@
export function backupUploadButton(onSuccess = null) {
const uniqueId = "backup_upload_" + app.utils.randomString();
const data = store({
isUploading: false,
});
function uploadConfirm(file) {
if (!file) {
return;
}
app.modals.confirm(
`Note that we don't perform validations for the uploaded backup files. Proceed with extreme caution and only if you trust the source.\n\n`
+ `Do you really want to upload "${file.name}"?`,
() => {
uploadBackup(file);
},
() => {
resetSelectedFile();
},
);
}
async function uploadBackup(file) {
if (!file || data.isUploading) {
return;
}
data.isUploading = true;
try {
const formData = new FormData();
formData.set("file", file);
await app.pb.backups.upload(formData, { requestKey: uniqueId });
data.isUploading = false;
onSuccess(file);
app.toasts.success("Successfully uploaded a new backup.");
} catch (err) {
if (!err.isAbort) {
data.isUploading = false;
if (err.response?.formData?.file?.message) {
app.toasts.error(err.response.formData.file.message);
} else {
app.checkApiError(err);
}
}
}
resetSelectedFile();
}
function resetSelectedFile() {
if (fileInput) {
fileInput.value = "";
}
}
const fileInput = t.input({
type: "file",
accept: "application/zip",
className: "hidden",
onchange: (e) => {
uploadConfirm(e.target?.files?.[0]);
},
});
return t.div(
null,
t.button(
{
type: "button",
ariaDescription: app.attrs.tooltip("Upload backup"),
className: () => `btn sm transparent secondary circle ${data.isUploading ? "loading" : ""}`,
disabled: () => data.isUploading,
onclick: () => fileInput?.click(),
onunmount: () => {
app.pb.cancelRequest(uniqueId);
},
},
t.i({ className: "ri-upload-cloud-line" }),
),
fileInput,
);
}

View File

@@ -0,0 +1,287 @@
export function backupsForm(propsArg = {}) {
const props = store({
onsave: null,
});
const watchers = app.utils.extendStore(props, propsArg);
const presets = [
{ cron: "0 0 * * *", label: "Every day at 00:00h" },
{ cron: "0 0 * * 0", label: "Every sunday at 00:00h" },
{ cron: "0 0 * * 1,3", label: "Every Mon and Wed at 00:00h" },
{ cron: "0 0 1 * *", label: "Every first day of the month at 00:00h" },
];
const data = store({
showForm: false,
isLoading: false,
isSaving: false,
formSettings: null,
initSerialized: "null",
enableAutoBackups: false,
get hasChanges() {
return data.initSerialized != JSON.stringify(data.formSettings);
},
});
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;
try {
const redacted = app.utils.filterRedactedProps(data.formSettings);
const settings = await app.pb.settings.update(redacted);
props.onsave?.(settings);
init(settings);
app.toasts.success("Successfully saved backups settings.");
} catch (err) {
app.checkApiError(err);
}
data.isSaving = false;
}
function init(settings = {}) {
// refresh local app settings
app.store.settings = JSON.parse(JSON.stringify(settings));
data.formSettings = {
backups: settings?.backups || {},
};
data.enableAutoBackups = !!data.formSettings.backups.cron;
data.initSerialized = JSON.stringify(data.formSettings);
}
function reset() {
data.formSettings = JSON.parse(data.initSerialized);
data.enableAutoBackups = !!data.formSettings.backups.cron;
}
watchers.push(
watch(() => {
if (!data.enableAutoBackups && data.formSettings?.backups?.cron) {
data.formSettings.backups.cron = "";
}
}),
);
return t.div(
{
className: "block backups-settings-form-wrapper",
onmount: () => {
loadSettings();
},
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
t.button(
{
type: "button",
className: () => `btn secondary ${data.isLoading ? "loading" : ""}`,
disabled: () => data.isLoading || data.hasChanges,
onclick: () => (data.showForm = !data.showForm),
},
t.span({ className: "txt" }, "Backup options"),
t.i({
className: () => (data.showForm ? "ri-arrow-up-s-line" : "ri-arrow-down-s-line"),
}),
),
app.components.slide(
() => data.showForm,
t.form(
{
pbEvent: "backupsSettingsForm",
className: "grid backups-settings-form m-t-base",
inert: () => data.isSaving,
onsubmit: (e) => {
e.preventDefault();
save();
},
},
() => {
if (data.isLoading) {
return t.div({ className: "col-lg-12 txt-center" }, t.span({ className: "loader lg" }));
}
return [
t.div(
{ className: "col-lg-12" },
t.div(
{ className: "field" },
t.input({
id: "enableAutoBackupsToggle",
type: "checkbox",
className: "switch",
checked: () => data.enableAutoBackups,
onchange: (e) => {
data.enableAutoBackups = e.target.checked;
if (!data.formSettings.backups.cron) {
data.formSettings.backups.cron = presets[0].cron;
}
},
}),
t.label({ htmlFor: "enableAutoBackupsToggle" }, "Enable auto backups"),
),
app.components.slide(
() => data.enableAutoBackups,
t.div(
{ className: "grid m-t-base m-b-base" },
t.div(
{ className: "col-lg-6" },
t.div(
{ className: "fields" },
t.div(
{ className: "field" },
t.label({ htmlFor: "backups.cron" }, "Cron expression"),
t.input({
id: "backups.cron",
name: "backups.cron",
className: "txt-code",
type: "text",
placeholder: "e.g. 0 0 * * *",
required: () => data.enableAutoBackups,
value: () => data.formSettings.backups.cron,
oninput: (e) => (data.formSettings.backups.cron = e.target.value),
}),
),
t.div(
{ className: "field addon" },
t.button(
{
type: "button",
className: "btn outline sm",
"html-popovertarget": "cron-presets-dropdown",
},
t.span({ className: "txt" }, "Presets"),
t.i({ className: "ri-arrow-drop-down-line" }),
),
t.div(
{
id: "cron-presets-dropdown",
className: "dropdown sm txt-nowrap",
popover: "auto",
},
() => {
return presets.map((preset) => {
return t.button({
type: "button",
className: () =>
`dropdown-item ${
data.formSettings.backups.cron == preset.cron
? "active"
: ""
}`,
textContent: preset.label,
onclick: (e) => {
data.formSettings.backups.cron = preset.cron;
e.target.closest(".dropdown").hidePopover();
},
});
});
},
),
),
),
t.div(
{ className: "field-help" },
"Supports numeric list, steps, ranges or ",
t.strong(
{
className: "link-hint tooltip-bottom",
ariaDescription: app.attrs.tooltip(
"@yearly\n@annually\n@monthly\n@weekly\n@daily\n@midnight\n@hourly",
),
},
"macros",
),
".",
t.br(),
"By default the timezone is in UTC.",
),
),
t.div(
{ className: "col-lg-6" },
t.div(
{ className: "field" },
t.label({ htmlFor: "backups.cronMaxKeep" }, "Max @auto backups to keep"),
t.input({
id: "backups.cronMaxKeep",
name: "backups.cronMaxKeep",
type: "number",
required: () => data.enableAutoBackups,
min: 1,
value: () => data.formSettings.backups.cronMaxKeep,
oninput: (e) => {
data.formSettings.backups.cronMaxKeep = parseInt(
e.target.value,
10,
);
},
}),
),
),
),
),
),
t.div(
{ className: "col-lg-12" },
app.components.s3ConfigFields({
toggleLabel: "Store backups in S3 storage",
testFilesystem: "backups",
config: () => data.formSettings.backups.s3,
}),
),
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(
{
hidden: () => !data.hasChanges,
type: "button",
className: "btn transparent secondary",
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"),
),
),
),
];
},
),
),
);
}

View File

@@ -0,0 +1,225 @@
import { openBackupCreateModal } from "./backupCreateModal";
import { openBackupRestoreModal } from "./backupRestoreModal";
export function backupsList(propsArg = {}) {
const props = store({
reset: null,
});
const watchers = app.utils.extendStore(props, propsArg);
const data = store({
canBackup: true,
isLoading: false,
isDownloading: {},
isDeleting: {},
backups: [],
});
async function loadBackups() {
data.isLoading = true;
try {
data.backups = await app.pb.backups.getFullList();
// sort backups DESC by their modified date
data.backups.sort((a, b) => {
if (a.modified < b.modified) {
return 1;
}
if (a.modified > b.modified) {
return -1;
}
return 0;
});
data.isLoading = false;
} catch (err) {
if (!err.isAbort) {
app.checkApiError(err);
data.isLoading = false;
}
}
}
async function confirmBackupDelete(key) {
app.modals.confirm(`Do you really want to delete ${key}?`, () => deleteBackup(key));
}
async function deleteBackup(key) {
if (data.isDeleting[key]) {
return;
}
data.isDeleting[key] = true;
try {
await app.pb.backups.delete(key);
loadBackups();
app.toasts.success(`Successfully deleted ${key}.`);
} catch (err) {
app.checkApiError(err);
}
delete data.isDeleting[key];
}
async function loadCanBackup() {
try {
const health = await app.pb.health.check({ requestKey: null });
const oldCanBackup = data.canBackup;
data.canBackup = health?.data?.canBackup || false;
// reload backups list
if (data.canBackup && oldCanBackup != data.canBackup) {
loadBackups();
}
} catch (err) {
console.warn("failed to load canBackup checks", err);
}
}
async function downloadBackup(key) {
if (data.isDownloading[key]) {
return;
}
data.isDownloading[key] = true;
try {
const token = await app.pb.files.getToken({ requestKey: null });
app.utils.download(app.pb.backups.getDownloadURL(token, key));
} catch (err) {
app.checkApiError(err);
}
delete data.isDownloading[key];
}
return t.div(
{
className: "list",
onmount: (el) => {
watchers.push(watch(() => props.reset, () => {
loadBackups();
}));
el._canBackupIntervalId = setInterval(() => {
loadCanBackup();
}, 3500);
},
onunmount: (el) => {
clearInterval(el._canBackupIntervalId);
watchers.forEach((w) => w?.unwatch());
},
},
t.div(
{
hidden: () => !data.isLoading || data.backups.length,
className: "list-item",
},
t.div({ className: "skeleton-loader" }),
),
t.div(
{
hidden: () => data.isLoading || data.backups.length,
className: () => "list-item",
},
t.div({ className: "content block txt-hint" }, "No backups found."),
),
() => {
return data.backups.map((backup) => {
return t.div(
{ className: () => `list-item ${data.isLoading ? "faded" : ""}` },
t.i({ className: "ri-folder-zip-line" }),
t.div(
{ className: "content" },
t.span({
className: "backup-name txt-ellipsis",
title: () => backup.key,
textContent: () => backup.key,
}),
t.small(
{ className: "backup-size txt-hint txt-nowrap" },
"(",
() => app.utils.formattedFileSize(backup.size),
")",
),
),
t.nav(
{
hidden: () => data.isLoading,
className: "actions autohide",
},
t.button(
{
type: "button",
ariaDescription: app.attrs.tooltip("Download"),
className: () =>
`btn sm circle secondary transparent ${
data.isDownloading[backup.key] ? "loading" : ""
}`,
disabled: () => data.isDeleting[backup.key] || data.isDownloading[backup.key],
onclick: () => downloadBackup(backup.key),
},
t.i({ className: "ri-download-line" }),
),
t.button(
{
type: "button",
ariaDescription: app.attrs.tooltip("Restore"),
className: () => `btn sm circle secondary transparent`,
disabled: () => data.isDeleting[backup.key] || data.isDownloading[backup.key],
onclick: () => openBackupRestoreModal(backup.key),
},
t.i({ className: "ri-restart-line" }),
),
t.button(
{
type: "button",
ariaDescription: app.attrs.tooltip("Delete"),
className: () =>
`btn sm circle secondary transparent ${
data.isDeleting[backup.key] ? "loading" : ""
}`,
disabled: () => data.isDeleting[backup.key] || data.isDownloading[backup.key],
onclick: () => confirmBackupDelete(backup.key),
},
t.i({ className: "ri-delete-bin-7-line" }),
),
),
);
});
},
t.div(
{ className: "list-item" },
t.button(
{
type: "button",
className: () => `btn secondary block ${data.isLoading ? "loading" : ""}`,
disabled: () => !data.canBackup || data.isLoading,
onclick: () => {
openBackupCreateModal({
oncreated: () => loadBackups(),
});
},
},
() => {
if (data.canBackup) {
return [
t.i({ className: "ri-play-circle-line" }),
t.span({ className: "txt" }, "Initialize new backup"),
];
}
return [
t.span({ className: "loader sm" }),
t.span({ className: "txt" }, "Backup/restore operation is in process"),
];
},
),
),
);
}

View File

@@ -0,0 +1,62 @@
import { settingsSidebar } from "../settingsSidebar";
import { backupsForm } from "./backupsForm";
import { backupsList } from "./backupsList";
import { backupUploadButton } from "./backupUploadButton";
export function pageBackupsSettings(route) {
app.store.title = "Backups";
const data = store({
resetList: null,
});
function resetBackupsList() {
data.resetList = Date.now();
}
return t.div(
{ pbEvent: "pageBackupsSettings", className: "page page-backups-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" }, () => app.store.title),
),
),
t.div(
{ className: "wrapper m-b-base" },
t.div(
{
className: "grid",
},
t.div(
{ className: "col-lg-12" },
t.div(
{ className: "flex gap-10 m-b-sm" },
t.div({ className: "txt-lg" }, "Backup and restore your PocketBase data"),
app.components.refreshButton({
className: "btn sm transparent secondary circle tooltip-bottom",
onclick: resetBackupsList,
}),
backupUploadButton(resetBackupsList),
),
backupsList({
reset: () => data.resetList,
}),
),
t.div(
{ className: "col-lg-12" },
backupsForm({
onsave: () => resetBackupsList(),
}),
),
),
),
t.footer({ className: "page-footer" }, app.components.credits()),
),
);
}

View File

@@ -0,0 +1,113 @@
export function cronsList(propsArg = {}) {
const props = store({
reset: null,
});
const watchers = app.utils.extendStore(props, propsArg);
const data = store({
isLoading: false,
isRunning: {},
crons: [],
});
async function loadCrons() {
data.isLoading = true;
try {
data.crons = await app.pb.crons.getFullList();
data.isLoading = false;
} catch (err) {
if (!err.isAbort) {
app.checkApiError(err);
data.isLoading = false;
}
}
}
async function runCron(jobId) {
if (!jobId || data.isRunning[jobId]) {
return;
}
data.isRunning[jobId] = true;
try {
await app.pb.crons.run(jobId);
app.toasts.success(`Successfully triggered "${jobId}".`);
data.isRunning[jobId] = false;
} catch (err) {
if (!err.isAbort) {
ApiClient.error(err);
data.isRunning[jobId] = false;
}
}
}
return t.div(
{
pbEvent: "cronsList",
className: "list",
onmount: () => {
watchers.push(
watch(() => props.reset, () => {
loadCrons();
}),
);
},
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
() => {
if (!data.isLoading || data.crons.length) {
return;
}
const skeletons = [];
for (let i = 0; i < 4; i++) {
skeletons.push(
t.div({ rid: "skeleton_" + i, className: "list-item" }, t.div({ className: "skeleton-loader" })),
);
}
return skeletons;
},
t.div(
{
hidden: () => data.isLoading || data.crons.length,
className: "list-item",
},
t.div({ className: "content block txt-hint" }, "No registered crons found."),
),
() => {
return data.crons.map((cron) => {
return t.div(
{ className: () => `list-item ${data.isLoading ? "faded" : ""}` },
t.div(
{ className: "content" },
t.span({
className: "cron-id txt-code txt-ellipsis",
title: () => cron.id,
textContent: () => cron.id,
}),
),
t.small({ className: "cron-expression txt-hint txt-nowrap txt-code" }, () => cron.expression),
t.nav(
{ hidden: () => data.isLoading, className: "actions" },
t.button(
{
type: "button",
ariaDescription: app.attrs.tooltip("Run"),
className: () =>
`btn sm circle secondary transparent ${data.isRunning[cron.id] ? "loading" : ""}`,
disabled: () => data.isRunning[cron.id],
onclick: () => runCron(cron.id),
},
t.i({ className: "ri-play-large-line" }),
),
),
);
});
},
);
}

View File

@@ -0,0 +1,63 @@
import { settingsSidebar } from "../settingsSidebar";
import { cronsList } from "./cronsList";
export function pageCronsSettings(route) {
app.store.title = "Crons";
const data = store({
resetList: null,
});
function resetCronsList() {
data.resetList = Date.now();
}
return t.div(
{ pbEvent: "pageCronsSettings", className: "page" },
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" }, () => app.store.title),
),
),
t.div(
{ className: "wrapper m-b-base" },
t.div(
{ className: "flex gap-10 m-b-sm" },
t.div({ className: "txt-lg" }, "Registered app cron jobs"),
app.components.refreshButton({
className: "btn sm transparent secondary circle",
onclick: resetCronsList,
}),
),
cronsList({
reset: () => data.resetList,
}),
t.div(
{ className: "txt-sm txt-hint m-t-sm" },
"App cron jobs can be registered only programmatically with ",
t.a({
href: `${import.meta.env.PB_DOCS_URL}/go-jobs-scheduling/`,
target: "_blank",
rel: "noopener noreferrer",
textContent: "Go",
}),
" or ",
t.a({
href: `${import.meta.env.PB_DOCS_URL}/js-jobs-scheduling/`,
target: "_blank",
rel: "noopener noreferrer",
textContent: "JavaScript",
}),
".",
),
),
t.footer({ className: "page-footer" }, app.components.credits()),
),
);
}

View File

@@ -0,0 +1,203 @@
window.app = window.app || {};
window.app.modals = window.app.modals || {};
window.app.modals.openMailTest = function(preselectedCollectionIdOrName = "", template = "") {
const modal = mailTestModal(preselectedCollectionIdOrName, template);
document.body.appendChild(modal);
app.modals.open(modal);
};
function mailTestModal(preselectedCollectionIdOrName = "", template = "") {
const uniqueId = "mail_test_" + app.utils.randomString();
const emailStorageKey = "pbLastTestEmail";
const testRequestKey = "email_test_request";
const templateOptions = [
{ label: "Verification", value: "verification" },
{ label: "Password reset", value: "password-reset" },
{ label: "Confirm email change", value: "email-change" },
{ label: "OTP", value: "otp" },
{ label: "Login alert", value: "login-alert" },
];
const data = store({
email: localStorage.getItem(emailStorageKey) || app.store.superuser?.email || "",
template: template || templateOptions[0].value,
isSending: false,
collectionIdOrName: preselectedCollectionIdOrName,
get isAuthCollectionsLoading() {
return app.store.isCollectionsLoading;
},
get authCollections() {
return app.utils.sortedCollections(
app.store.collections.filter((c) => c.type == "auth"),
);
},
get canSubmit() {
return !!data.email && !!data.template && !!data.collectionIdOrName;
},
});
let testTimeoutId;
async function send() {
if (data.isSending || !data.canSubmit) {
return;
}
data.isSending = true;
// auto cancel the test request after 15sec
clearTimeout(testTimeoutId);
testTimeoutId = setTimeout(() => {
data.isSending = false;
app.pb.cancelRequest(testRequestKey);
app.modals.close();
app.toasts.error("Test email send timeout.");
}, 15000);
try {
// store as preset
if (data.email != app.pb.authStore.record?.email) {
localStorage.setItem(emailStorageKey, data.email);
}
await app.pb.settings.testEmail(data.collectionIdOrName, data.email, data.template, {
requestKey: testRequestKey,
});
app.toasts.success("Successfully sent test email.");
app.modals.close();
} catch (err) {
app.checkApiError(err);
}
data.isSending = false;
clearTimeout(testTimeoutId);
}
const watchers = [];
return t.div(
{
className: "modal popup sm",
onbeforeopen: (el) => {
// preselect the first auth collection as fallback
watchers.push(
watch(() => data.isAuthCollectionsLoading, (isLoading) => {
if (!isLoading && !data.collectionIdOrName) {
data.collectionIdOrName = data.authCollections[0]?.id || "";
}
}),
);
},
onafterclose: (el) => {
clearTimeout(testTimeoutId);
el?.remove();
},
onunmount: () => {
clearTimeout(testTimeoutId);
watchers.forEach((w) => w?.unwatch());
},
},
t.header({ className: "modal-header" }, t.h5({ className: "m-auto" }, "Send test email")),
t.form(
{
id: uniqueId,
className: "modal-content mail-settings-test-form",
onsubmit: (e) => {
e.preventDefault();
send();
},
},
t.div(
{ className: "grid" },
t.div({ className: "col-lg-12" }, () => {
return templateOptions.map((opt, i) => {
return t.field(
{ className: () => `field ${i > 0 ? "m-t-10" : ""}` },
t.input({
type: "radio",
id: uniqueId + ".template." + opt.value,
name: "template",
checked: () => data.template == opt.value,
onchange: (e) => (data.template = opt.value),
}),
t.label({ htmlFor: uniqueId + ".template." + opt.value }, opt.label || opt.value),
);
});
}),
() => {
if (preselectedCollectionIdOrName) {
return;
}
return t.div(
{ className: "col-lg-12" },
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + ".collection" }, "Auth collection"),
app.components.select({
id: uniqueId + ".collection",
name: "collection",
required: true,
placeholder: () =>
data.isAuthCollectionsLoading
? "Loading auth collections..."
: "Select auth collection",
options: () =>
data.authCollections.map((c) => {
return { value: c.id, label: c.name };
}),
value: () => data.collectionIdOrName || "",
onchange: (selected) => {
data.collectionIdOrName = selected?.[0]?.value;
},
}),
),
);
},
t.div(
{ className: "col-lg-12" },
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + ".email" }, "To email address"),
t.input({
id: uniqueId + ".email",
name: "email",
type: "email",
required: true,
value: () => data.email || "",
oninput: (e) => (data.email = e.target.value),
}),
),
),
),
),
t.footer(
{ className: "modal-footer" },
t.button(
{
type: "button",
className: "btn transparent m-r-auto",
onclick: () => app.modals.close(),
disabled: () => data.isSending,
},
t.span({ className: "txt" }, "Close"),
),
t.button(
{
"html-form": uniqueId,
type: "submit",
className: () => `btn expanded ${data.isSending ? "loading" : ""}`,
disabled: () => data.isSending || !data.canSubmit,
},
t.i({ className: "ri-mail-send-line" }),
t.span({ className: "txt" }, "Send"),
),
),
);
}

View File

@@ -0,0 +1,385 @@
import { settingsSidebar } from "../settingsSidebar";
export function pageMailSettings(route) {
app.store.title = "Mail settings";
const tlsOptions = [
{ label: "Auto (StartTLS)", value: false },
{ label: "Always", value: true },
];
const authMethods = [
{ label: "PLAIN (default)", value: "PLAIN" },
{ label: "LOGIN", value: "LOGIN" },
];
const data = store({
isLoading: false,
isSaving: false,
formSettings: null,
initSerialized: "null",
showMoreOptions: false,
get hasChanges() {
return data.initSerialized != 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;
try {
const redacted = app.utils.filterRedactedProps(data.formSettings);
const settings = await app.pb.settings.update(redacted);
init(settings);
app.toasts.success("Successfully saved mail settings.");
} catch (err) {
app.checkApiError(err);
}
data.isSaving = false;
}
function init(settings = {}) {
// refresh local app settings
app.store.settings = JSON.parse(JSON.stringify(settings));
data.formSettings = {
meta: settings?.meta || {},
smtp: settings?.smtp || {},
};
if (!data.formSettings.smtp.authMethod) {
data.formSettings.smtp.authMethod = authMethods[0].value;
}
data.initSerialized = JSON.stringify(data.formSettings);
}
function reset() {
data.formSettings = JSON.parse(data.initSerialized);
}
return t.div(
{ pbEvent: "pageMailSettings", className: "page page-mail-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" }, () => app.store.title),
),
),
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: "mailSettingsForm",
className: "grid mail-settings-form",
inert: () => data.isSaving,
onsubmit: (e) => {
e.preventDefault();
save();
},
},
t.div(
{ className: "col-lg-12 txt-lg" },
t.p(null, "Configure common settings for sending emails."),
),
t.div(
{ className: "col-lg-6" },
t.div(
{ className: "field" },
t.label({ htmlFor: "meta.senderName" }, "Sender name"),
t.input({
id: "meta.senderName",
name: "meta.senderName",
type: "text",
required: true,
value: () => data.formSettings.meta.senderName || "",
oninput: (e) => (data.formSettings.meta.senderName = e.target.value),
}),
),
),
t.div(
{ className: "col-lg-6" },
t.div(
{ className: "field" },
t.label({ htmlFor: "meta.senderAddress" }, "Sender address"),
t.input({
id: "meta.senderAddress",
name: "meta.senderAddress",
type: "email",
required: true,
value: () => data.formSettings.meta.senderAddress || "",
oninput: (e) => (data.formSettings.meta.senderAddress = e.target.value),
}),
),
),
t.div(
{ className: "col-lg-12" },
t.div(
{ className: "field" },
t.input({
id: "smtp.enabled",
name: "smtp.enabled",
type: "checkbox",
className: "switch",
checked: () => !!data.formSettings.smtp.enabled,
onchange: (e) => (data.formSettings.smtp.enabled = e.target.checked),
}),
t.label(
{ htmlFor: "smtp.enabled" },
t.span(
{ className: "txt" },
"Use SMTP mail server ",
t.strong(null, "(recommended)"),
),
t.i({
className: "ri-information-line link-faded",
ariaDescription: app.attrs.tooltip(
`By default PocketBase uses the unix "sendmail" command for sending emails. For better emails deliverability it is recommended to use a SMTP mail server.`,
),
}),
),
),
// SMTP
app.components.slide(
() => data.formSettings.smtp.enabled,
t.div(
{ className: "grid m-t-sm" },
t.div(
{ className: "col-lg-4" },
t.div(
{ className: "field" },
t.label({ htmlFor: "smtp.host" }, "SMTP server host"),
t.input({
id: "smtp.host",
name: "smtp.host",
type: "text",
required: () => data.formSettings.smtp.enabled,
value: () => data.formSettings.smtp.host || "",
oninput: (e) => data.formSettings.smtp.host = e.target.value,
}),
),
),
t.div(
{ className: "col-lg-2" },
t.div(
{ className: "field" },
t.label({ htmlFor: "smtp.port" }, "Port"),
t.input({
id: "smtp.port",
name: "smtp.port",
type: "number",
min: 0,
step: 1,
required: () => data.formSettings.smtp.enabled,
value: () => data.formSettings.smtp.port || "",
oninput: (e) =>
data.formSettings.smtp.port = parseInt(e.target.value, 10),
}),
),
),
t.div(
{ className: "col-lg-3" },
t.div(
{ className: "field" },
t.label({ htmlFor: "smtp.username" }, "Username"),
t.input({
id: "smtp.username",
name: "smtp.username",
type: "text",
autocomplete: "off",
value: () => data.formSettings.smtp.username || "",
oninput: (e) => data.formSettings.smtp.username = e.target.value,
}),
),
),
t.div(
{ className: "col-lg-3" },
t.div(
{ className: "field" },
t.label({ htmlFor: "smtp.password" }, "Password"),
t.input({
id: "smtp.password",
name: "smtp.password",
type: "password",
autocomplete: "new-password",
value: () => data.formSettings.smtp.password || "",
oninput: (e) => data.formSettings.smtp.password = e.target.value,
onkeyup: (e) => {
if (
e.key == "Backspace"
&& typeof data.formSettings.smtp.password === "undefined"
) {
data.formSettings.smtp.password = "";
}
},
placeholder: () =>
typeof data.formSettings.smtp.password !== "undefined"
? ""
: "* * * * * *",
}),
),
),
),
// additional options
t.button(
{
type: "button",
className: "btn secondary sm m-t-sm",
onclick: () => data.showMoreOptions = !data.showMoreOptions,
},
t.span(
{ className: "txt" },
() => data.showMoreOptions ? "Hide more options" : "Show more options",
),
t.i({
className: () =>
data.showMoreOptions ? "ri-arrow-drop-up-line" : "ri-arrow-drop-down-line",
}),
),
app.components.slide(
() => data.showMoreOptions,
t.div(
{ className: "grid m-t-sm" },
t.div(
{ className: "col-lg-3" },
t.div(
{ className: "field" },
t.label({ htmlFor: "smtp.tls" }, "TLS encryption"),
app.components.select({
id: "smtp.tls",
name: "smtp.tls",
required: true,
options: tlsOptions,
value: () => data.formSettings.smtp.tls || false,
onchange: (selected) => {
data.formSettings.smtp.tls = selected?.[0]?.value;
},
}),
),
),
t.div(
{ className: "col-lg-3" },
t.div(
{ className: "field" },
t.label({ htmlFor: "smtp.authMethod" }, "AUTH method"),
app.components.select({
id: "smtp.authMethod",
name: "smtp.authMethod",
required: true,
options: authMethods,
value: () =>
data.formSettings.smtp.authMethod || authMethods[0].value,
onchange: (selected) => {
data.formSettings.smtp.authMethod = selected?.[0]?.value;
},
}),
),
),
t.div(
{ className: "col-lg-6" },
t.div(
{ className: "field" },
t.label(
{ htmlFor: "smtp.localName" },
t.span({ className: "txt" }, "EHLO/HELO domain"),
t.i({
className: "ri-information-line link-hint tooltip-top",
ariaDescription: app.attrs.tooltip(
"Some SMTP servers, such as the Gmail SMTP-relay, requires a proper domain name in the inital EHLO/HELO exchange and will reject attempts to use localhost.",
),
}),
),
t.input({
id: "smtp.localName",
name: "smtp.localName",
type: "text",
placeholder: "Default to localhost",
value: () => data.formSettings.smtp.localName || "",
oninput: (e) => data.formSettings.smtp.localName = e.target.value,
}),
),
),
),
),
),
),
t.div({ className: "col-lg-12" }, t.hr()),
t.div(
{ className: "col-lg-12" },
t.div(
{ className: "flex" },
t.div({ className: "m-r-auto" }),
() => {
if (data.hasChanges) {
return [
t.button(
{
type: "button",
className: "btn transparent secondary",
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"),
),
];
}
return t.button(
{
type: "button",
className: () => `btn expanded outline`,
onclick: () => app.modals.openMailTest(),
},
t.i({ className: "ri-mail-check-line" }),
t.span({ className: "txt" }, "Send test email"),
);
},
),
),
);
},
),
t.footer({ className: "page-footer" }, app.components.credits()),
),
);
}

View File

@@ -0,0 +1,54 @@
export function settingsSidebar() {
return app.components.pageSidebar(
{
pbEvent: "settingsSidebar",
className: "settings-sidebar",
},
t.nav(
{ className: "sidebar-content scrollable" },
() => {
const result = [];
for (const groupName in app.store.settingsNavGroups) {
const children = app.store.settingsNavGroups[groupName];
const groupEl = t.details(
{ className: "nav-group", "html-data-group": groupName, open: true },
t.summary(
{ tabIndex: -1, onfocusout: () => false, onclick: () => false, onkeyup: () => false },
groupName,
),
() => {
return children.map((link) => {
const isLocal = link.href.startsWith("#/");
return t.a(
{
href: () => link.href,
target: () => !isLocal ? "_blank" : undefined,
rel: () => !isLocal ? "noopener noreferrer" : undefined,
className: (el) => {
const isActive = link.isActive?.(el)
|| app.utils.isActivePath(link.href, false);
return `nav-item ${isActive ? "active" : ""}`;
},
},
() => {
if (link.icon) {
return t.i({ className: link.icon });
}
},
t.span({ className: "txt" }, () => link.label),
);
});
},
);
result.push(groupEl);
}
return result;
},
),
);
}

View File

@@ -0,0 +1,187 @@
import { settingsSidebar } from "../settingsSidebar";
export function pageStorageSettings() {
app.store.title = "File storage";
const data = store({
isLoading: false,
isSaving: false,
formSettings: null,
initSerialized: "null",
originalFormSettings: null,
get hasChanges() {
return data.initSerialized != JSON.stringify(data.formSettings);
},
});
loadSettings();
async function loadSettings() {
data.isLoading = true;
try {
init(await app.pb.settings.getAll());
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;
try {
const redacted = app.utils.filterRedactedProps(data.formSettings);
const settings = await app.pb.settings.update(redacted);
init(settings);
app.toasts.success("Successfully saved storage settings.");
} catch (err) {
app.checkApiError(err);
}
data.isSaving = false;
}
function init(settings = {}) {
// refresh local app settings
app.store.settings = JSON.parse(JSON.stringify(settings));
data.formSettings = {
s3: settings?.s3 || {},
};
data.initSerialized = JSON.stringify(data.formSettings);
data.originalFormSettings = JSON.parse(data.initSerialized);
}
function reset() {
data.formSettings = JSON.parse(data.initSerialized);
}
return t.div(
{
pbEvent: "pageStorageSettings",
className: "page page-storage-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" }, () => app.store.title),
),
),
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: "storageSettingsForm",
className: "grid storage-settings-form",
inert: () => data.isSaving,
onsubmit: (e) => {
e.preventDefault();
save();
},
},
t.div(
{ className: "col-lg-12 txt-lg" },
t.p(
null,
"By default PocketBase uses and recommends the local file system to store uploaded files because it is more performant, easier to manage and backup.",
),
t.p(
null,
"Alternatively, if you have limited disk space available, you could opt to an S3 compatible external storage.",
),
),
t.div(
{ className: "col-lg-12" },
app.components.s3ConfigFields({
config: () => data.formSettings.s3,
before: () => {
const originalEnabled = data.originalFormSettings.s3?.enabled;
if (originalEnabled == data.formSettings.s3?.enabled) {
return;
}
return t.div(
{ className: "alert info m-t-sm" },
"If you have existing uploaded files, you'll have to migrate them manually from the ",
t.strong(null, originalEnabled ? "S3 storage" : "local file system"),
" to the ",
t.strong(
null,
data.formSettings.s3?.enabled ? "S3 storage" : "local file system",
),
".",
t.br(),
"There are several command line tools that can help you, such as: ",
t.a({
href: "https://github.com/rclone/rclone",
target: "_blank",
rel: "noopener noreferrer",
className: "txt-bold",
textContent: "rclone",
}),
", ",
t.a({
href: "https://github.com/peak/s5cmd",
target: "_blank",
rel: "noopener noreferrer",
className: "txt-bold",
textContent: "s5cmd",
}),
", etc.",
);
},
}),
),
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(
{
hidden: () => !data.hasChanges,
type: "button",
className: "btn transparent secondary",
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()),
),
);
}

View File

@@ -0,0 +1,319 @@
export function collectionsDiffTable(propsArg = {}) {
const props = store({
rid: undefined,
collectionA: null,
collectionB: null,
deleteMissing: false,
className: "",
});
const watchers = app.utils.extendStore(props, propsArg);
const data = store({
hasAnyChange: false,
get isDeleteDiff() {
return !props.collectionB?.id && !props.collectionB?.name;
},
get isCreateDiff() {
return !data.isDeleteDiff && !props.collectionA?.id;
},
get hasAnyChange() {
return app.utils.hasCollectionChanges(props.collectionA, props.collectionB, props.deleteMissing);
},
get fieldsListA() {
return Array.isArray(props.collectionA?.fields) ? props.collectionA?.fields : [];
},
get fieldsListB() {
let fieldsB = Array.isArray(props.collectionB?.fields) ? props.collectionB?.fields : [];
if (!props.deleteMissing) {
fieldsB = fieldsB.concat(
props.collectionA?.fields?.filter((a) => {
return !fieldsB.find((b) => a.id == b.id);
}) || [],
);
}
return fieldsB;
},
get mainModelProps() {
return app.utils
.mergeUnique(Object.keys(props.collectionA || {}), Object.keys(props.collectionB || {}))
.filter((key) => {
return !["fields", "created", "updated"].includes(key);
});
},
get removedFields() {
return data.fieldsListA.filter((a) => {
return !data.fieldsListB.find((b) => a.id == b.id);
});
},
get sharedFields() {
return data.fieldsListB.filter((b) => {
return data.fieldsListA.find((a) => a.id == b.id);
});
},
get addedFields() {
return data.fieldsListB.filter((b) => {
return !data.fieldsListA.find((a) => a.id == b.id);
});
},
});
function stringify(value) {
if (typeof value == "undefined") {
return "";
}
return app.utils.isObject(value) ? JSON.stringify(value, null, 4) : "" + value;
}
function isDifferent(valA, valB) {
if (valA === valB) {
return false; // direct match
}
return JSON.stringify(valA) != JSON.stringify(valB);
}
function getFieldById(fields, id) {
return (fields || []).find((f) => f.id == id);
}
return t.div(
{
rid: props.rid,
pbEvent: "collectionsDiffTableWrapper",
className: () => `collections-diff-table-wrapper ${props.className}`,
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
t.div(
{ className: "collections-diff-table-title" },
() => {
if (!props.collectionA?.id) {
return [
t.span({
className: "label import-change-label success",
textContent: "Added",
}),
t.strong({ textContent: () => props.collectionB?.name }),
];
}
if (!props.collectionB?.id) {
return [
t.span({
className: "label import-change-label danger",
textContent: "Deleted",
}),
t.strong({ textContent: () => props.collectionA?.name }),
];
}
return [
t.span({
hidden: () => !data.hasAnyChange,
className: "label import-change-label warning",
textContent: "Changed",
}),
t.div(
{ className: "inline-flex gap-5" },
() => {
if (props.collectionA?.name == props.collectionB?.name) {
return;
}
return [
t.strong({
className: "txt-strikethrough txt-hint",
textContent: props.collectionA?.name,
}),
t.i({
className: "ri-arrow-right-line txt-sm",
}),
];
},
t.strong({ textContent: () => props.collectionB?.name }),
),
];
},
),
t.table(
{ className: "collections-diff-table" },
t.thead(
null,
t.tr(
null,
t.th({ className: "min-width" }, "Props"),
t.th({ width: "40%" }, "Old"),
t.th({ width: "40%" }, "New"),
),
),
t.tbody(
null,
() => {
return data.mainModelProps.map((p) => {
const isDiff = isDifferent(props.collectionA?.[p], props.collectionB?.[p]);
return t.tr(
{ className: isDiff ? "txt-primary" : "" },
t.td({ className: "min-width" }, p),
t.td(
{
className: () => {
if (data.isCreateDiff) {
return "changed-non-col";
}
if (isDiff) {
return "changed-old-col";
}
return "";
},
},
t.pre({ className: "txt diff-value" }, stringify(props.collectionA?.[p])),
),
t.td(
{
className: () => {
if (data.isDeleteDiff) {
return "changed-non-col";
}
if (isDiff) {
return "changed-new-col";
}
return "";
},
},
t.pre({ className: "txt diff-value" }, stringify(props.collectionB?.[p])),
),
);
});
},
() => {
if (!props.deleteMissing && !data.isDeleteDiff) {
return;
}
const rows = [];
for (let field of data.removedFields) {
rows.push(
t.tr(
null,
t.th(
{ className: "min-width", colSpan: 3 },
t.span({ className: "txt" }, "field: ", field.name),
t.span(
{ className: "label danger m-l-5" },
"Deleted - ",
t.small(null, `All stored data related to '${field.name}' will be deleted!`),
),
),
),
);
for (let key in field) {
const val = field[key];
rows.push(
t.tr(
null,
t.td({ className: "min-width field-key-col" }, key),
t.td(
{ className: "changed-old-col" },
t.pre({ className: "txt" }, stringify(val)),
),
t.td({ className: "changed-none-col" }),
),
);
}
}
return rows;
},
() => {
const rows = [];
for (let field of data.sharedFields) {
const fieldA = getFieldById(data.fieldsListA, field.id);
const fieldB = getFieldById(data.fieldsListB, field.id);
const hasFieldChanged = isDifferent(fieldA, fieldB);
rows.push(
t.tr(
null,
t.th(
{ className: "min-width", colSpan: 3 },
t.span({ className: "txt" }, "field: ", field.name),
t.span({
className: `label warning m-l-5 ${!hasFieldChanged ? "hidden" : ""}`,
textContent: "Changed",
}),
),
),
);
for (let key in field) {
const newValue = field[key];
const isDiff = isDifferent(fieldA?.[key], newValue);
rows.push(
t.tr(
{ className: isDiff ? "txt-primary" : "" },
t.td({ className: "min-width field-key-col" }, key),
t.td(
{ className: isDiff ? "changed-old-col" : "" },
t.pre({ className: "txt" }, stringify(fieldA?.[key])),
),
t.td(
{ className: isDiff ? "changed-new-col" : "" },
t.pre({ className: "txt" }, stringify(newValue)),
),
),
);
}
}
return rows;
},
() => {
const rows = [];
for (let field of data.addedFields) {
rows.push(
t.tr(
null,
t.th(
{ className: "min-width", colSpan: 3 },
t.span({ className: "txt" }, "field: ", field.name),
t.span({ className: "label success m-l-5" }, "Added"),
),
),
);
for (let key in field) {
const val = field[key];
rows.push(
t.tr(
{ className: "txt-primary" },
t.td({ className: "min-width field-key-col" }, key),
t.td({ className: "changed-none-col" }),
t.td(
{ className: "changed-new-col" },
t.pre({ className: "txt" }, stringify(val)),
),
),
);
}
}
return rows;
},
),
),
);
}

View File

@@ -0,0 +1,180 @@
import { collectionsDiffTable } from "./collectionsDiffTable";
window.app = window.app || {};
window.app.modals = window.app.modals || {};
window.app.modals.openImportCollectionsReview = function(oldCollections, newCollections, settings = {
deleteMissing: false,
onsubmit: null,
}) {
const modal = importCollectionsModal(oldCollections, newCollections, settings);
if (!modal) {
return;
}
document.body.appendChild(modal);
app.modals.open(modal);
};
function importCollectionsModal(oldCollections, newCollections, settingsArg) {
let modal;
const settings = store({
deleteMissing: false,
onsubmit: function(newCollections) {},
});
const watchers = app.utils.extendStore(settings, settingsArg);
const data = store({
isImporting: false,
pairs: [],
});
function loadPairs() {
const pairs = [];
// add modified and deleted (if deleteMissing is set)
for (const oldCollection of oldCollections) {
const newCollection = newCollections.find((c) => c.id == oldCollection.id);
if (
(settings.deleteMissing && !newCollection?.id)
|| (newCollection?.id
&& app.utils.hasCollectionChanges(oldCollection, newCollection, settings.deleteMissing))
) {
pairs.push({
old: oldCollection,
new: newCollection,
});
}
}
// add only new collections
for (const newCollection of newCollections) {
const oldCollection = oldCollections.find((c) => c.id == newCollection.id);
if (!oldCollection?.id) {
pairs.push({
old: oldCollection,
new: newCollection,
});
}
}
data.pairs = pairs;
}
function submitConfirm() {
// find deleted fields
const deletedFieldNames = [];
if (settings.deleteMissing) {
for (const old of oldCollections) {
const imported = newCollections.find((c) => c.id == old.id);
if (!imported) {
// add all fields
deletedFieldNames.push(old.name + ".*");
} else {
// add only deleted fields
const fields = Array.isArray(old.fields) ? old.fields : [];
for (const field of fields) {
if (!imported.fields.find((f) => f.id == field.id)) {
deletedFieldNames.push(`${old.name}.${field.name} (${field.id})`);
}
}
}
}
}
if (deletedFieldNames.length) {
app.modals.confirm(
[
t.h6(
null,
"Do you really want to delete the following collection fields and their related records data:",
),
t.ul(null, () => {
return deletedFieldNames.map((name) => {
return t.li(null, name);
});
}),
],
() => submit(),
);
} else {
submit();
}
}
async function submit() {
if (data.isImporting) {
return;
}
data.isImporting = true;
try {
await app.pb.collections.import(newCollections, settings.deleteMissing);
await app.store.loadCollections();
settings.onsubmit?.(JSON.parse(JSON.stringify(app.store.collections)));
app.toasts.success("Successfully imported collections configuration.");
} catch (err) {
app.checkApiError(err);
}
data.isImporting = false;
app.modals.close(modal);
}
modal = t.div(
{
pbEvent: "importCollectionsReviewModal",
className: "modal popup full import-collections-review-modal",
onbeforeopen: () => {
loadPairs();
},
onbeforeclose: () => {
return !data.isImporting;
},
onafterclose: (el) => {
el?.remove();
},
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
t.header({ className: "modal-header" }, t.h5(null, "Side-by-side diff")),
t.div({ className: "modal-content" }, () => {
return data.pairs.map((pair) => {
return collectionsDiffTable({
collectionA: pair.old,
collectionB: pair.new,
deleteMissing: settings.deleteMissing,
});
});
}),
t.footer(
{ className: "modal-footer" },
t.button(
{
type: "button",
className: "btn transparent m-r-auto",
disabled: () => data.isImporting,
onclick: () => app.modals.close(modal),
},
t.span({ className: "txt" }, "Close"),
),
t.button(
{
type: "button",
className: () => `btn expanded ${data.isImporting ? "loading" : ""}`,
disabled: () => data.isImporting,
onclick: () => submitConfirm(),
},
t.span({ className: "txt" }, "Confirm and import"),
),
),
);
return modal;
}

View File

@@ -0,0 +1,191 @@
import { settingsSidebar } from "../settingsSidebar";
export function pageExportCollections(route) {
app.store.title = "Export collections";
const uniqueId = "export_" + app.utils.randomString();
const data = store({
isLoading: false,
collections: [],
bulkSelected: {},
get bulkSelectStr() {
return JSON.stringify(app.utils.sortedCollectionsByType(Object.values(data.bulkSelected)), null, 2);
},
get totalSelected() {
return Object.keys(data.bulkSelected).length;
},
get areAllSelected() {
return data.collections.length && data.collections.length == data.totalSelected;
},
});
loadCollections();
async function loadCollections() {
data.isLoading = true;
try {
let collections = await app.pb.collections.getFullList({
requestKey: uniqueId,
});
for (let collection of collections) {
// delete timestamps
delete collection.created;
delete collection.updated;
// unset oauth2 providers
delete collection.oauth2?.providers;
}
data.collections = app.utils.sortedCollectionsByType(collections);
selectAll();
data.isLoading = false;
} catch (err) {
if (!err.isAbort) {
app.checkApiError(err);
data.isLoading = false;
}
}
}
function download() {
const collectionsArr = app.utils.sortedCollectionsByType(Object.values(data.bulkSelected));
app.utils.downloadJSON(collectionsArr, "pb_schema");
}
function toggleSelectAll() {
if (data.areAllSelected) {
deselectAll();
} else {
selectAll();
}
}
function deselectAll() {
data.bulkSelected = {};
}
// note: allways assign a new object to trigger the getter's Object.keys
function selectAll() {
data.bulkSelected = {};
for (const collection of data.collections) {
data.bulkSelected[collection.id] = collection;
}
}
function toggleSelectCollection(collection) {
const bulkSelected = JSON.parse(JSON.stringify(data.bulkSelected));
if (!data.bulkSelected[collection.id]) {
bulkSelected[collection.id] = collection;
} else {
delete bulkSelected[collection.id];
}
// reassign to trigger the getter's Object.keys
data.bulkSelected = bulkSelected;
}
return t.div(
{
pbEvent: "pageExportCollections",
className: "page page-export-collections",
},
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" }, () => app.store.title),
),
),
t.div({ className: "wrapper m-b-base" }, () => {
if (data.isLoading) {
return t.div({ className: "txt-center" }, t.span({ className: "loader lg" }));
}
return t.div(
{ className: "grid" },
t.div(
{ className: "col-lg-12" },
t.div(
{ className: "txt-lg" },
"Below you'll find your current collections configuration that you could import in another PocketBase environment.",
),
),
t.div(
{ className: "col-lg-12" },
t.div(
{ className: "export-panel" },
t.aside(
{ className: "export-list" },
t.div(
{ className: "list-item" },
t.div(
{ className: "field" },
t.input({
id: uniqueId + ".select_all",
type: "checkbox",
checked: () => data.areAllSelected,
onchange: () => toggleSelectAll(),
}),
t.label({ htmlFor: uniqueId + ".select_all" }, "Select all"),
),
),
() => {
return data.collections.map((collection) => {
const checkboxId = uniqueId + "_c_" + collection.id;
return t.div(
{ className: "list-item" },
t.div(
{ className: "field" },
t.input({
id: checkboxId,
type: "checkbox",
checked: () => !!data.bulkSelected[collection.id],
onchange: () => {
toggleSelectCollection(collection);
},
}),
t.label({ htmlFor: checkboxId }, collection.name),
),
);
});
},
),
t.output(
{ className: "export-preview" },
app.components.codeBlock({
value: () => data.bulkSelectStr,
// disable highlight bacause it can cause
// performance issue with too many collections
language: "plain",
}),
t.nav(
{ className: "ctrls" },
app.components.copyButton(() => data.bulkSelectStr),
),
),
),
),
t.div(
{ className: "col-lg-12 txt-right" },
t.button(
{ className: "btn", onclick: download },
t.i({ className: "ri-download-line" }),
t.span({ className: "txt" }, "Download as JSON"),
),
),
);
}),
t.footer({ className: "page-footer" }, app.components.credits()),
),
);
}

View File

@@ -0,0 +1,536 @@
import { settingsSidebar } from "../settingsSidebar";
export function pageImportCollections(route) {
app.store.title = "Import collections";
const uniqueId = "import_" + app.utils.randomString();
const watchers = [];
const data = store({
rawNewCollections: "",
oldCollections: [],
newCollections: [],
collectionsToUpdate: [],
deleteMissing: true,
isLoadingFile: false,
isLoadingOldCollections: false,
mergeWithOldCollections: false, // an alternative to the default deleteMissing option
get isRawValid() {
return (
!!data.rawNewCollections
&& data.newCollections?.length > 0
&& data.newCollections.length == data.newCollections.filter((c) => !!c.id && !!c.name).length
);
},
get collectionsToDelete() {
return data.oldCollections.filter((oldC) => {
return (
data.isRawValid
&& !data.mergeWithOldCollections
&& data.deleteMissing
&& !data.newCollections.find((c) => c.id == oldC.id)
);
});
},
get collectionsToAdd() {
return data.newCollections.filter((newC) => {
return data.isRawValid && !data.oldCollections.find((c) => c.id == newC.id);
});
},
// @see replaceIds()
get idReplacableCollections() {
return data.newCollections.filter((collection) => {
let old = data.oldCollections.find((c) => c.name == collection.name || c.id == collection.id);
if (!old) {
return false; // new
}
if (old.id != collection.id) {
return true;
}
// check for matching schema fields
const oldFields = Array.isArray(old.fields) ? old.fields : [];
const newFields = Array.isArray(collection.fields) ? collection.fields : [];
for (const field of newFields) {
const oldFieldById = oldFields.find((f) => f.id == field.id);
if (oldFieldById) {
continue; // no need to do any replacements
}
const oldFieldByName = oldFields.find((f) => f.name == field.name);
if (oldFieldByName && field.id != oldFieldByName.id) {
return true;
}
}
return false;
});
},
get hasChanges() {
return (
!!data.rawNewCollections
&& !!(data.collectionsToDelete.length || data.collectionsToAdd.length
|| data.collectionsToUpdate.length)
);
},
get canReview() {
return !data.isLoadingOldCollections && data.isRawValid && data.hasChanges;
},
});
const fileInput = t.input({
id: uniqueId + "_load_json",
type: "file",
className: "hidden",
accept: ".json",
onchange: () => {
loadFile(fileInput.files?.[0]);
},
});
loadOldCollections();
async function loadOldCollections() {
data.isLoadingOldCollections = true;
try {
const collections = await app.pb.collections.getFullList();
for (let collection of collections) {
// delete timestamps
delete collection.created;
delete collection.updated;
// unset oauth2 providers
delete collection.oauth2?.providers;
}
data.oldCollections = collections;
data.isLoadingOldCollections = false;
} catch (err) {
if (!err.isAbort) {
app.checkApiError(err);
data.isLoadingOldCollections = false;
}
}
}
watchers.push(
watch(
() => data.rawNewCollections,
() => {
loadNewCollections();
},
),
);
function loadNewCollections() {
let collections = [];
try {
collections = JSON.parse(data.rawNewCollections);
if (!Array.isArray(collections)) {
collections = [];
} else {
collections = app.utils.filterDuplicatesByKey(collections);
}
// normalizations
for (let collection of collections) {
// delete timestamps
delete collection.created;
delete collection.updated;
// merge fields with duplicated ids
if (collection.fields) {
collection.fields = app.utils.filterDuplicatesByKey(collection.fields);
}
}
} catch (_) {}
data.newCollections = collections;
}
watchers.push(
watch(
() => [data.newCollections, data.deleteMissing],
() => {
loadCollectionsToUpdate();
},
),
);
function loadCollectionsToUpdate() {
data.collectionsToUpdate = [];
if (!data.isRawValid) {
return;
}
for (let newCollection of data.newCollections) {
const oldCollection = data.oldCollections.find((c) => c.id == newCollection.id);
if (
// no old collection
!oldCollection?.id
// no changes
|| !app.utils.hasCollectionChanges(oldCollection, newCollection, data.deleteMissing)
) {
continue;
}
data.collectionsToUpdate.push({
new: newCollection,
old: oldCollection,
});
}
}
function replaceIds() {
for (let collection of data.newCollections) {
const old = data.oldCollections.find((c) => c.name == collection.name || c.id == collection.id);
if (!old) {
continue;
}
const originalId = collection.id;
const replacedId = old.id;
collection.id = replacedId;
// replace field ids
const oldFields = Array.isArray(old.fields) ? old.fields : [];
const newFields = Array.isArray(collection.fields) ? collection.fields : [];
for (const field of newFields) {
const oldField = oldFields.find((f) => f.name == field.name);
if (oldField && oldField.id) {
field.id = oldField.id;
}
}
// update references
for (let ref of data.newCollections) {
if (!Array.isArray(ref.fields)) {
continue;
}
for (let field of ref.fields) {
if (field.collectionId && field.collectionId === originalId) {
field.collectionId = replacedId;
}
}
}
// update index names that contains the collection id
for (let i = 0; i < collection.indexes?.length; i++) {
collection.indexes[i] = collection.indexes[i].replace(
/create\s+(?:unique\s+)?\s*index\s*(?:if\s+not\s+exists\s+)?(\S*)\s+on/gim,
(v) => v.replace(originalId, replacedId),
);
}
}
data.rawNewCollections = JSON.stringify(data.newCollections, null, 2);
}
function clear() {
data.rawNewCollections = "";
fileInput.value = "";
app.store.errors = null;
}
function loadFile(file) {
data.isLoadingFile = true;
const reader = new FileReader();
reader.onload = async (event) => {
data.isLoadingFile = false;
fileInput.value = ""; // reset
data.rawNewCollections = event.target.result;
await new Promise((r) => setTimeout(r, 0));
if (!data.newCollections.length) {
app.toasts.error("Invalid collections configuration.");
clear();
}
};
reader.onerror = (err) => {
app.toasts.error("Failed to load the imported JSON.");
console.warn(err);
data.isLoadingFile = false;
fileInput.value = ""; // reset
};
reader.readAsText(file);
}
function review() {
const collectionsToImport = !data.mergeWithOldCollections
? data.newCollections
: app.utils.filterDuplicatesByKey(data.oldCollections.concat(data.newCollections));
app.modals.openImportCollectionsReview(data.oldCollections, collectionsToImport, {
deleteMissing: data.deleteMissing,
onsubmit: () => {
clear();
loadOldCollections();
},
});
}
return t.div(
{
pbEvent: "pageImportCollections",
className: "page page-import-collections",
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
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" }, () => app.store.title),
),
),
t.div({ className: "wrapper m-b-base" }, () => {
if (data.isLoadingOldCollections) {
return t.div({ className: "block txt-center" }, t.span({ className: "loader lg" }));
}
return t.div(
{ className: "grid" },
t.div(
{ className: "col-lg-12" },
t.span(
{ className: "txt-lg m-r-5" },
"Paste below the collections configuration you want to import or",
),
t.label(
{
htmlFor: fileInput.id,
className: () => `btn sm outline ${data.isLoadingFile ? "loading" : ""}`,
},
t.span({ className: "txt" }, "Load from JSON file"),
),
fileInput,
t.p(
{ className: "txt-hint" },
t.em(
null,
"You can use the ",
t.a({
href: `${import.meta.env.PB_DOCS_URL}/go-migrations/`,
target: "_blank",
rel: "noopener noreferrer",
textContent: "Go",
}),
" or ",
t.a({
href: `${import.meta.env.PB_DOCS_URL}/js-migrations/`,
target: "_blank",
rel: "noopener noreferrer",
textContent: "JS",
}),
" migrations to manage your collections programmatically in more granular and version controlled manner.",
),
),
),
t.div(
{ className: "col-lg-12" },
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + "_collections_field" }, "Collections"),
t.textarea({
id: uniqueId + "_collections_field",
name: "collections",
rows: 12,
className: "txt-code",
spellcheck: false,
autocorrect: false,
autocomplete: "off",
autocapitalize: "off",
value: () => data.rawNewCollections,
oninput: (e) => (data.rawNewCollections = e.target.value),
}),
),
t.div(
{
className: () =>
`field-help error ${!!data.rawNewCollections && !data.isRawValid ? "" : "hidden"}`,
},
"Invalid collections configuration.",
),
),
t.div(
{ className: () => `col-lg-12 ${!data.isRawValid ? "hidden" : ""}` },
t.div(
{ className: "field" },
t.input({
id: uniqueId + "_merge_checkbox",
type: "checkbox",
className: "switch",
checked: () => data.mergeWithOldCollections,
onchange: (e) => (data.mergeWithOldCollections = e.target.checked),
}),
t.label({ htmlFor: uniqueId + "_merge_checkbox" }, "Merge with the existing collections"),
),
),
t.div(
{
className: () => `col-lg-12 ${data.isRawValid && !data.hasChanges ? "" : "hidden"}`,
},
t.div(
{ className: "alert info" },
t.div(
{ className: "content" },
t.p(null, "Your collections configuration is already up-to-date!"),
),
),
),
t.div(
{
className: () => `col-lg-12 ${data.isRawValid && data.hasChanges ? "" : "hidden"}`,
},
t.p({ className: "txt-hint txt-bold" }, "Detected changes"),
t.div(
{ className: "list" },
// to delete
() => {
return data.collectionsToDelete.map((collection) => {
return t.div(
{ className: "list-item" },
t.span({
className: "label import-change-label danger",
textContent: "Deleted",
}),
t.div(
{ className: "inline-flex gap-5" },
t.strong({ textContent: () => collection.name }),
t.small({
className: () => `txt-hint ${!collection.id ? "hidden" : ""}`,
textContent: () => collection.id,
}),
),
);
});
},
// to update
() => {
return data.collectionsToUpdate.map((pair) => {
return t.div(
{ className: "list-item" },
t.span({
className: "label import-change-label warning",
textContent: "Changed",
}),
t.div(
{ className: "inline-flex gap-5" },
() => {
if (pair.old.name == pair.new.name) {
return;
}
return [
t.span({
className: "txt-strikethrough txt-hint",
textContent: pair.old.name,
}),
t.i({
className: "ri-arrow-right-line txt-sm",
}),
];
},
t.strong({ textContent: () => pair.new.name }),
t.small({
className: () => `txt-hint ${!pair.new.id ? "hidden" : ""}`,
textContent: () => pair.new.id,
}),
),
);
});
},
// to add
() => {
return data.collectionsToAdd.map((collection) => {
return t.div(
{ className: "list-item" },
t.span({
className: "label import-change-label success",
textContent: "Added",
}),
t.div(
{ className: "inline-flex gap-5" },
t.strong({ textContent: () => collection.name }),
t.small({
className: () => `txt-hint ${!collection.id ? "hidden" : ""}`,
textContent: () => collection.id,
}),
),
);
});
},
),
),
t.div(
{
className: () => `col-lg-12 ${!data.idReplacableCollections?.length ? "hidden" : ""}`,
},
t.div(
{ className: "alert warning" },
t.div(
{ className: "content" },
t.p(
null,
"Some of the imported collections share the same name and/or fields but are imported with different IDs.",
),
t.p(
null,
"You can replace them in the import if you want to:",
t.button({
type: "button",
className: "btn warning sm m-l-10",
textContent: "Replace with original IDs",
onclick: replaceIds,
}),
),
),
),
),
t.div(
{ className: "col-lg-12" },
t.div(
{ className: "flex" },
t.button(
{
type: "button",
className: () => `btn secondary ${!data.rawNewCollections ? "hidden" : ""}`,
onclick: clear,
},
t.span({ className: "txt" }, "Clear"),
),
t.button(
{
type: "button",
className: "btn expanded-lg m-l-auto",
disabled: () => !data.canReview,
onclick: review,
},
t.span({ className: "txt" }, "Review"),
),
),
),
);
}),
t.footer({ className: "page-footer" }, app.components.credits()),
),
);
}