merge newui branch
This commit is contained in:
121
ui/src/settings/application/batchAccordion.js
Normal file
121
ui/src/settings/application/batchAccordion.js
Normal 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),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
287
ui/src/settings/application/pageApplicationSettings.js
Normal file
287
ui/src/settings/application/pageApplicationSettings.js
Normal 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;
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
415
ui/src/settings/application/rateLimitAccordion.js
Normal file
415
ui/src/settings/application/rateLimitAccordion.js
Normal 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"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
152
ui/src/settings/application/rateLimitInfoModal.js
Normal file
152
ui/src/settings/application/rateLimitInfoModal.js
Normal 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"),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
240
ui/src/settings/application/trustedProxyAccordion.js
Normal file
240
ui/src/settings/application/trustedProxyAccordion.js
Normal 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;
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
151
ui/src/settings/backups/backupCreateModal.js
Normal file
151
ui/src/settings/backups/backupCreateModal.js
Normal 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;
|
||||
}
|
||||
181
ui/src/settings/backups/backupRestoreModal.js
Normal file
181
ui/src/settings/backups/backupRestoreModal.js
Normal 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"),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
89
ui/src/settings/backups/backupUploadButton.js
Normal file
89
ui/src/settings/backups/backupUploadButton.js
Normal 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,
|
||||
);
|
||||
}
|
||||
287
ui/src/settings/backups/backupsForm.js
Normal file
287
ui/src/settings/backups/backupsForm.js
Normal 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"),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
225
ui/src/settings/backups/backupsList.js
Normal file
225
ui/src/settings/backups/backupsList.js
Normal 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"),
|
||||
];
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
62
ui/src/settings/backups/pageBackupsSettings.js
Normal file
62
ui/src/settings/backups/pageBackupsSettings.js
Normal 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()),
|
||||
),
|
||||
);
|
||||
}
|
||||
113
ui/src/settings/crons/cronsList.js
Normal file
113
ui/src/settings/crons/cronsList.js
Normal 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" }),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
63
ui/src/settings/crons/pageCronsSettings.js
Normal file
63
ui/src/settings/crons/pageCronsSettings.js
Normal 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()),
|
||||
),
|
||||
);
|
||||
}
|
||||
203
ui/src/settings/mail/mailTestModal.js
Normal file
203
ui/src/settings/mail/mailTestModal.js
Normal 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"),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
385
ui/src/settings/mail/pageMailSettings.js
Normal file
385
ui/src/settings/mail/pageMailSettings.js
Normal 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()),
|
||||
),
|
||||
);
|
||||
}
|
||||
54
ui/src/settings/settingsSidebar.js
Normal file
54
ui/src/settings/settingsSidebar.js
Normal 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;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
187
ui/src/settings/storage/pageStorageSettings.js
Normal file
187
ui/src/settings/storage/pageStorageSettings.js
Normal 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()),
|
||||
),
|
||||
);
|
||||
}
|
||||
319
ui/src/settings/sync/collectionsDiffTable.js
Normal file
319
ui/src/settings/sync/collectionsDiffTable.js
Normal 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;
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
180
ui/src/settings/sync/importCollectionsReviewModal.js
Normal file
180
ui/src/settings/sync/importCollectionsReviewModal.js
Normal 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;
|
||||
}
|
||||
191
ui/src/settings/sync/pageExportCollections.js
Normal file
191
ui/src/settings/sync/pageExportCollections.js
Normal 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()),
|
||||
),
|
||||
);
|
||||
}
|
||||
536
ui/src/settings/sync/pageImportCollections.js
Normal file
536
ui/src/settings/sync/pageImportCollections.js
Normal 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()),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user