merge newui branch
This commit is contained in:
104
ui/src/collections/addCollectionFieldButton.js
Normal file
104
ui/src/collections/addCollectionFieldButton.js
Normal file
@@ -0,0 +1,104 @@
|
||||
window.app = window.app || {};
|
||||
window.app.components = window.app.components || {};
|
||||
|
||||
window.app.components.addCollectionFieldButton = function(collection) {
|
||||
const uniqueId = "new_field_" + app.utils.randomString();
|
||||
|
||||
function addNewField(fieldType) {
|
||||
const field = {
|
||||
id: "",
|
||||
name: getUniqueFieldName(fieldType),
|
||||
type: fieldType,
|
||||
system: false,
|
||||
hidden: false,
|
||||
presentable: false,
|
||||
required: false,
|
||||
|
||||
__focus: true, // see fieldSettings
|
||||
};
|
||||
|
||||
collection.fields = collection.fields || [];
|
||||
|
||||
// if the collection has created/updated last fields,
|
||||
// insert before the first autodate field, otherwise - append
|
||||
const idx = collection.fields.findLastIndex((f) => f.type != "autodate");
|
||||
if (field.type != "autodate" && idx >= 0) {
|
||||
collection.fields.splice(idx + 1, 0, field);
|
||||
} else {
|
||||
collection.fields.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
function getUniqueFieldName(baseName = "") {
|
||||
let result = baseName;
|
||||
let counter = 2;
|
||||
|
||||
let suffix = baseName.match(/\d+$/)?.[0] || ""; // extract numeric suffix
|
||||
|
||||
// name without the suffix
|
||||
let base = suffix ? baseName.substring(0, baseName.length - suffix.length) : baseName;
|
||||
|
||||
while (hasFieldWithName(result)) {
|
||||
result = base + ((suffix << 0) + counter);
|
||||
counter++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function hasFieldWithName(name) {
|
||||
return !!collection.fields?.find((f) => f.name.toLowerCase() === name.toLowerCase());
|
||||
}
|
||||
|
||||
return t.div(
|
||||
{ className: "new-collection-field-btn-wrapper" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "btn block outline",
|
||||
"html-popovertarget": uniqueId + "_dropdown",
|
||||
},
|
||||
t.i({ className: "ri-add-line" }),
|
||||
t.span({ className: "txt" }, "New field"),
|
||||
),
|
||||
t.div(
|
||||
{
|
||||
id: uniqueId + "_dropdown",
|
||||
className: "dropdown field-types-dropdown",
|
||||
popover: "auto",
|
||||
},
|
||||
() => {
|
||||
const options = [];
|
||||
|
||||
for (const type in app.fieldTypes) {
|
||||
// for now skip password field types
|
||||
if (type == "password") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const def = app.fieldTypes[type];
|
||||
if (!def.settings) {
|
||||
continue;
|
||||
}
|
||||
|
||||
options.push(
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "dropdown-item",
|
||||
onclick: (e) => {
|
||||
e.target.closest(".dropdown")?.hidePopover();
|
||||
addNewField(type);
|
||||
},
|
||||
},
|
||||
t.i({ className: def.icon || app.utils.fallbackFieldIcon }),
|
||||
t.span({ className: "txt" }, def.label || type),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return options;
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
246
ui/src/collections/autocomplete.utils.js
Normal file
246
ui/src/collections/autocomplete.utils.js
Normal file
@@ -0,0 +1,246 @@
|
||||
window.app = window.app || {};
|
||||
window.app.utils = window.app.utils || {};
|
||||
|
||||
const defaultOptions = {
|
||||
maxKeys: 30,
|
||||
requestKeys: true,
|
||||
collectionJoinKeys: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates an array with the suitable autocomplete words for the targeted collection.
|
||||
*
|
||||
* @param {string|Object} targetCollection Collection model or identifier.
|
||||
* @param {string} word The autocomplete triggered "word".
|
||||
* @param {Object} [options]
|
||||
* @param {number} [options.maxKeys] The max number of returned autocomplete keys (default to 30).
|
||||
* @param {boolean} [options.requestKeys] Whether to include the `@request.*` keys (default to true).
|
||||
* @param {boolean} [options.collectionJoinKeys] Whether to include the `@collection.*` keys (default to true).
|
||||
* @return {Array}
|
||||
*/
|
||||
window.app.utils.collectionAutocompleteKeys = function(targetCollection, word, options = {}) {
|
||||
if (!targetCollection || !word || !app.store.collections?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
options = Object.assign({}, defaultOptions, options);
|
||||
|
||||
let result = collectionFieldsAutocomplete(word, app.store.collections, targetCollection).sort(keysSort);
|
||||
|
||||
if (options.requestKeys) {
|
||||
const keys = requestFieldsAutocomplete(word, app.store.collections, targetCollection).sort(keysSort);
|
||||
for (let k of keys) {
|
||||
result.push(k);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.collectionJoinKeys) {
|
||||
const keys = collectionJoinAutocomplete(word, app.store.collections).sort(keysSort);
|
||||
for (let k of keys) {
|
||||
result.push(k);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.length > options.maxKeys) {
|
||||
return result.slice(0, options.maxKeys);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// sort shorter keys first
|
||||
function keysSort(a, b) {
|
||||
return a.length - b.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates recursively a list with all the autocomplete field keys
|
||||
* for the collectionNameOrId collection.
|
||||
*
|
||||
* @param {string} word
|
||||
* @param {Array} collections
|
||||
* @param {string|object} collection
|
||||
* @param {string} [prefix]
|
||||
* @param {number} [level]
|
||||
* @return {Array}
|
||||
*/
|
||||
function collectionFieldsAutocomplete(word, collections, collection, prefix = "", level = 0) {
|
||||
if (!word || level >= 4) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (typeof collection == "string") {
|
||||
collection = collections.find((c) => c.name == collection || c.id == collection);
|
||||
}
|
||||
if (!collection) {
|
||||
return [];
|
||||
}
|
||||
|
||||
word = word.toLowerCase();
|
||||
|
||||
const isAuth = collection.type == "auth";
|
||||
|
||||
const result = app.utils
|
||||
.getAllCollectionIdentifiers(collection, prefix)
|
||||
.filter((item) => item.toLowerCase().includes(word));
|
||||
|
||||
const fields = collection.fields || [];
|
||||
for (const field of fields) {
|
||||
if (field.type == "password" || (isAuth && field.name == "tokenKey")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const keys = [];
|
||||
|
||||
// special @request.body modifiers
|
||||
if (prefix == "@request.body.") {
|
||||
keys.push(prefix + field.name + ":changed");
|
||||
keys.push(prefix + field.name + ":isset");
|
||||
}
|
||||
|
||||
if (typeof app.fieldTypes[field.type]?.filterModifiers == "function") {
|
||||
const modifiers = app.fieldTypes[field.type]?.filterModifiers(field) || [];
|
||||
for (const m of modifiers) {
|
||||
keys.push(prefix + field.name + ":" + m);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
if (key.toLowerCase().includes(word)) {
|
||||
result.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// add relation fields
|
||||
if (field.type == "relation" && field.collectionId) {
|
||||
const subKeys = collectionFieldsAutocomplete(
|
||||
word,
|
||||
collections,
|
||||
field.collectionId,
|
||||
prefix + field.name + ".",
|
||||
level + 1,
|
||||
);
|
||||
for (const k of subKeys) {
|
||||
result.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add back relations
|
||||
for (const ref of collections) {
|
||||
const refFields = ref.fields || [];
|
||||
for (const field of refFields) {
|
||||
if (field.type != "relation" || field.collectionId != collection.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = prefix + ref.name + "_via_" + field.name;
|
||||
const subKeys = collectionFieldsAutocomplete(word, collections, ref, key + ".", level + 2); // +2 to reduce the recursive results
|
||||
for (const k of subKeys) {
|
||||
result.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a list with all @request.* autocomplete field keys.
|
||||
*
|
||||
* @param {string} word
|
||||
* @param {Array} collections
|
||||
* @param {string|object} baseCollection (used for the `@request.body.*` fields)
|
||||
* @return {Array}
|
||||
*/
|
||||
function requestFieldsAutocomplete(word, collections, baseCollection) {
|
||||
if (!word) {
|
||||
return [];
|
||||
}
|
||||
|
||||
word = word.toLowerCase();
|
||||
|
||||
const result = [];
|
||||
|
||||
const common = [
|
||||
"@request.context",
|
||||
"@request.method",
|
||||
"@request.query.",
|
||||
"@request.body.",
|
||||
"@request.headers.",
|
||||
"@request.auth.collectionId",
|
||||
"@request.auth.collectionName",
|
||||
];
|
||||
for (const w of common) {
|
||||
if (!w.toLowerCase().includes(word)) {
|
||||
continue;
|
||||
}
|
||||
result.push(w);
|
||||
}
|
||||
|
||||
// load auth collection fields
|
||||
const authCollections = collections.filter((collection) => collection.type === "auth");
|
||||
for (const collection of authCollections) {
|
||||
if (collection.system) {
|
||||
continue; // skip system collections for now
|
||||
}
|
||||
const authKeys = collectionFieldsAutocomplete(word, collections, collection, "@request.auth.");
|
||||
for (const k of authKeys) {
|
||||
app.utils.pushUnique(result, k);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof baseCollection == "string") {
|
||||
baseCollection = collections.find((c) => c.name == baseCollection || c.id == baseCollection);
|
||||
}
|
||||
if (!baseCollection) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// load base collection fields into @request.body.*
|
||||
const keys = collectionFieldsAutocomplete(word, collections, baseCollection, "@request.body.");
|
||||
for (const key of keys) {
|
||||
result.push(key);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a list with all @collection.* autocomplete field keys.
|
||||
*
|
||||
* @param {string} word
|
||||
* @param {Array} collections
|
||||
* @return {Array}
|
||||
*/
|
||||
function collectionJoinAutocomplete(word, collections) {
|
||||
const result = [];
|
||||
|
||||
let basePrefix = "@collection.";
|
||||
|
||||
// to avoid unnecessary loading all @collection.* keys match with the word first
|
||||
let base, search;
|
||||
if (basePrefix.length < word.length) {
|
||||
base = word;
|
||||
search = basePrefix;
|
||||
} else {
|
||||
base = basePrefix;
|
||||
search = word;
|
||||
}
|
||||
if (!base.includes(search)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const collection of collections) {
|
||||
if (collection.system) {
|
||||
continue; // skip system collections for now
|
||||
}
|
||||
|
||||
const keys = collectionFieldsAutocomplete(word, collections, collection, basePrefix + collection.name + ".");
|
||||
for (const key of keys) {
|
||||
result.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
89
ui/src/collections/collectionAuthOptionsTab.js
Normal file
89
ui/src/collections/collectionAuthOptionsTab.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import { emailTemplateAccordion } from "./emailTemplateAccordion";
|
||||
import { mfaAccordion } from "./mfaAccordion";
|
||||
import { oauth2Accordion } from "./oauth2Accordion";
|
||||
import { otpAccordion } from "./otpAccordion";
|
||||
import { passwordAuthAccordion } from "./passwordAuthAccordion";
|
||||
import { tokenOptionsAccordion } from "./tokenOptionsAccordion";
|
||||
|
||||
export function collectionAuthOptionsTab(upsertData) {
|
||||
const uniqueId = "options_" + app.utils.randomString();
|
||||
|
||||
return t.div(
|
||||
{ className: "collection-tab-content collection-options-tab-content" },
|
||||
t.div(
|
||||
{ className: "grid" },
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "section-heading" },
|
||||
t.strong(null, "Auth methods"),
|
||||
t.div({ className: "flex-fill" }),
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.input({
|
||||
id: uniqueId + ".authAlert",
|
||||
name: "authAlert.enabled",
|
||||
type: "checkbox",
|
||||
className: "switch sm",
|
||||
checked: () => !!upsertData.collection.authAlert?.enabled,
|
||||
onchange: (e) => {
|
||||
upsertData.collection.authAlert = upsertData.collection.authAlert || {};
|
||||
upsertData.collection.authAlert.enabled = e.target.checked;
|
||||
},
|
||||
}),
|
||||
t.label({ htmlFor: uniqueId + ".authAlert" }, "Send email alert for new logins"),
|
||||
),
|
||||
),
|
||||
passwordAuthAccordion(upsertData.collection),
|
||||
() => {
|
||||
if (upsertData.originalCollection?.name == "_superusers") {
|
||||
return;
|
||||
}
|
||||
|
||||
return oauth2Accordion(upsertData.collection);
|
||||
},
|
||||
otpAccordion(upsertData.collection),
|
||||
mfaAccordion(upsertData.collection),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "section-heading" },
|
||||
t.strong(null, "Mail templates"),
|
||||
t.button({
|
||||
tabIndex: -1,
|
||||
type: "buttton",
|
||||
className: "m-l-auto label handle txt-bold",
|
||||
textContent: "Send test email",
|
||||
onclick: () => app.modals.openMailTest(upsertData.collection?.name),
|
||||
}),
|
||||
),
|
||||
emailTemplateAccordion(upsertData.collection, "verificationTemplate", {
|
||||
title: "Default Verification email template",
|
||||
placeholders: ["{APP_NAME}", "{APP_URL}", "{RECORD:*}", "{TOKEN}"],
|
||||
}),
|
||||
emailTemplateAccordion(upsertData.collection, "resetPasswordTemplate", {
|
||||
title: "Default Password reset email template",
|
||||
placeholders: ["{APP_NAME}", "{APP_URL}", "{RECORD:*}", "{TOKEN}"],
|
||||
}),
|
||||
emailTemplateAccordion(upsertData.collection, "confirmEmailChangeTemplate", {
|
||||
title: "Default Confirm email change email template",
|
||||
placeholders: ["{APP_NAME}", "{APP_URL}", "{RECORD:*}", "{TOKEN}"],
|
||||
}),
|
||||
emailTemplateAccordion(upsertData.collection, "otp.emailTemplate", {
|
||||
title: "Default OTP email template",
|
||||
placeholders: ["{APP_NAME}", "{APP_URL}", "{RECORD:*}", "{OTP}", "{OTP_ID}"],
|
||||
}),
|
||||
emailTemplateAccordion(upsertData.collection, "authAlert.emailTemplate", {
|
||||
title: "Default Login alert email template",
|
||||
placeholders: ["{APP_NAME}", "{APP_URL}", "{RECORD:*}", "{ALERT_INFO}"],
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div({ className: "section-heading" }, t.strong(null, "Other")),
|
||||
tokenOptionsAccordion(upsertData.collection),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
347
ui/src/collections/collectionChangesConfirmationModal.js
Normal file
347
ui/src/collections/collectionChangesConfirmationModal.js
Normal file
@@ -0,0 +1,347 @@
|
||||
import { toDeleteProp } from "@/base/fieldSettings";
|
||||
|
||||
window.app = window.app || {};
|
||||
window.app.modals = window.app.modals || {};
|
||||
|
||||
window.app.modals.openCollectionChangesConfirmation = async function(
|
||||
oldCollection,
|
||||
newCollection,
|
||||
yesCallback,
|
||||
noCallback,
|
||||
) {
|
||||
const data = store({
|
||||
isLoadingConflictingOIDCProviders: false,
|
||||
conflictingOIDCProviders: [],
|
||||
// ---
|
||||
get isCollectionRenamed() {
|
||||
return oldCollection?.name != newCollection?.name;
|
||||
},
|
||||
get isNewCollectionAuth() {
|
||||
return newCollection?.type === "auth";
|
||||
},
|
||||
get isNewCollectionView() {
|
||||
return newCollection?.type === "view";
|
||||
},
|
||||
get renamedFields() {
|
||||
if (data.isNewCollectionView) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return newCollection?.fields?.filter?.((f) => {
|
||||
let oldField;
|
||||
if (f.id && !f[toDeleteProp]) {
|
||||
oldField = oldCollection.fields?.find?.((old) => old.id == f.id);
|
||||
}
|
||||
|
||||
return oldField && oldField.name != f.name;
|
||||
}) || [];
|
||||
},
|
||||
get deletedFields() {
|
||||
if (data.isNewCollectionView) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return newCollection?.fields?.filter?.((f) => {
|
||||
return f.id && f[toDeleteProp];
|
||||
}) || [];
|
||||
},
|
||||
get multipleToSingleFields() {
|
||||
if (data.isNewCollectionView) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return newCollection?.fields?.filter?.((newField) => {
|
||||
const oldField = oldCollection?.fields?.find?.((f) => f.id == newField.id);
|
||||
if (!oldField || typeof oldField.maxSelect == "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// normalize
|
||||
const oldMaxSelect = oldField.maxSelect || 1;
|
||||
const newMaxSelect = newField.maxSelect || 1;
|
||||
|
||||
return oldMaxSelect > 1 && newMaxSelect == 1;
|
||||
}) || [];
|
||||
},
|
||||
get changedRules() {
|
||||
// for now enable only for "production"
|
||||
if (window.location.protocol != "https:") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = [];
|
||||
|
||||
const ruleProps = ["listRule", "viewRule"];
|
||||
if (!data.isNewCollectionView) {
|
||||
ruleProps.push("createRule", "updateRule", "deleteRule");
|
||||
}
|
||||
if (data.isNewCollectionAuth) {
|
||||
ruleProps.push("manageRule", "authRule");
|
||||
}
|
||||
|
||||
let oldRule, newRule;
|
||||
for (let prop of ruleProps) {
|
||||
oldRule = oldCollection?.[prop];
|
||||
newRule = newCollection?.[prop];
|
||||
if (oldRule === newRule) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push({ prop, oldRule, newRule });
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
get needConfirmation() {
|
||||
return !app.utils.isEmpty(oldCollection?.id) && (
|
||||
data.isCollectionRenamed
|
||||
|| data.renamedFields.length
|
||||
|| data.deletedFields.length
|
||||
|| data.multipleToSingleFields.length
|
||||
|| data.changedRules.length
|
||||
|| data.conflictingOIDCProviders.length
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const knownOIDCProviders = ["oidc", "oidc2", "oidc3"];
|
||||
|
||||
async function detectConflictingOIDCProviders() {
|
||||
if (app.utils.isEmpty(oldCollection?.id) || !data.isNewCollectionAuth) {
|
||||
return;
|
||||
}
|
||||
|
||||
data.isLoadingConflictingOIDCProviders = true;
|
||||
|
||||
try {
|
||||
data.conflictingOIDCProviders = [];
|
||||
|
||||
for (const name of knownOIDCProviders) {
|
||||
const oldProvider = oldCollection?.oauth2?.providers?.find?.((p) => p.name == name);
|
||||
const newProvider = newCollection?.oauth2?.providers?.find?.((p) => p.name == name);
|
||||
|
||||
if (!oldProvider || !newProvider) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const oldHost = new URL(oldProvider.authURL).host;
|
||||
const newHost = new URL(newProvider.authURL).host;
|
||||
if (oldHost == newHost) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// check if there are existing externalAuths
|
||||
const haveExternalAuths = await app.pb.collection("_externalAuths").getFirstListItem(
|
||||
app.pb.filter("collectionRef={:collectionId} && provider={:provider}", {
|
||||
collectionId: newCollection?.id,
|
||||
provider: name,
|
||||
}),
|
||||
{
|
||||
requestKey: null,
|
||||
},
|
||||
);
|
||||
if (haveExternalAuths) {
|
||||
data.conflictingOIDCProviders.push({ name, oldHost, newHost });
|
||||
}
|
||||
}
|
||||
|
||||
data.isLoadingConflictingOIDCProviders = false;
|
||||
} catch (err) {
|
||||
if (err.isAbort) {
|
||||
data.isLoadingConflictingOIDCProviders = false;
|
||||
app.checkApiError(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await detectConflictingOIDCProviders();
|
||||
|
||||
if (!data.needConfirmation) {
|
||||
return yesCallback();
|
||||
}
|
||||
|
||||
app.modals.confirm(
|
||||
t.div(
|
||||
{ className: "dangerous-collection-changes-list" },
|
||||
t.h5({ className: "block txt-center m-b-base" }, "Do you really want to save the collection changes?"),
|
||||
// general collection warning
|
||||
() => {
|
||||
if (!data.isCollectionRenamed && !data.deletedFields.length && !data.renamedFields.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return t.div(
|
||||
{ className: "alert warning m-b-base" },
|
||||
t.p(
|
||||
null,
|
||||
"If the collection participate in another collection rule, filter or view query, you'll have to update it manually!",
|
||||
),
|
||||
() => {
|
||||
if (data.deletedFields.length) {
|
||||
return t.p(
|
||||
null,
|
||||
"All data associated with the removed fields will be permanently deleted!",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
// renamed collection
|
||||
() => {
|
||||
if (!data.isCollectionRenamed) {
|
||||
return;
|
||||
}
|
||||
|
||||
return t.ul(
|
||||
{ className: "collection-changes-list changes-renamed-collection" },
|
||||
t.li(
|
||||
{ className: "list-item" },
|
||||
"Renamed collection ",
|
||||
t.strong({ className: "label warning" }, oldCollection?.name),
|
||||
t.i({ className: "ri-arrow-right-line txt-sm" }),
|
||||
t.strong({ className: "label success" }, newCollection?.name || "N/A"),
|
||||
),
|
||||
);
|
||||
},
|
||||
// renamed fields
|
||||
() => {
|
||||
if (!data.renamedFields.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return t.ul(
|
||||
{ className: "collection-changes-list changes-renamed-fields" },
|
||||
() => {
|
||||
return data.renamedFields.map((newField) => {
|
||||
const oldField = oldCollection?.fields?.find?.((f) => f.id == newField.id);
|
||||
return t.li(
|
||||
{ className: "list-item" },
|
||||
"Renamed field ",
|
||||
t.strong({ className: "label warning" }, oldField?.name),
|
||||
t.i({ className: "ri-arrow-right-line txt-sm" }),
|
||||
t.strong({ className: "label success" }, newField.name || "N/A"),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
// deleted fields
|
||||
() => {
|
||||
if (!data.deletedFields.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return t.ul(
|
||||
{ className: "collection-changes-list changes-deleted-fields" },
|
||||
() => {
|
||||
return data.deletedFields.map((field) => {
|
||||
return t.li(
|
||||
{ className: "list-item" },
|
||||
"Deleted field ",
|
||||
t.strong({ className: "label danger" }, field.name || "N/A"),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
// multiple->single fields
|
||||
() => {
|
||||
if (!data.multipleToSingleFields.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return t.ul(
|
||||
{ className: "collection-changes-list changes-multiple-to-single-fields" },
|
||||
() => {
|
||||
return data.multipleToSingleFields.map((field) => {
|
||||
return t.li(
|
||||
{ className: "list-item" },
|
||||
"Multiple to single value conversion of field ",
|
||||
t.strong({ className: "label warning" }, field.name || field.id),
|
||||
t.em({ className: "txt-sm" }, " (will keep only the last array item)"),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
// API rule changes
|
||||
() => {
|
||||
if (!data.changedRules.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return t.ul(
|
||||
{ className: "collection-changes-list changes-api-rules" },
|
||||
() => {
|
||||
return data.changedRules.map((ruleChange) => {
|
||||
return t.li(
|
||||
{ className: "list-item" },
|
||||
t.div(
|
||||
{ className: "content" },
|
||||
t.span({ className: "txt" }, "Changed API rule for "),
|
||||
t.code(null, ruleChange.prop),
|
||||
),
|
||||
t.small({ className: "txt-bold" }, "Old:"),
|
||||
t.div(
|
||||
{ className: "rule-content old-rule" },
|
||||
ruleChange.oldRule === null
|
||||
? "null (superusers only)"
|
||||
: (ruleChange.oldRule || "\"\""),
|
||||
),
|
||||
t.small({ className: "txt-bold" }, "New:"),
|
||||
t.div(
|
||||
{ className: "rule-content new-rule" },
|
||||
ruleChange.newRule === null
|
||||
? "null (superusers only)"
|
||||
: (ruleChange.newRule || "\"\""),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
// Conflicting OIDC changes
|
||||
() => {
|
||||
if (!data.conflictingOIDCProviders.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return t.ul(
|
||||
{ className: "collection-changes-list changes-api-rules" },
|
||||
() => {
|
||||
return data.conflictingOIDCProviders.map((oidc) => {
|
||||
return t.li(
|
||||
{ className: "list-item" },
|
||||
"Changed OIDC ",
|
||||
oidc.name,
|
||||
" host ",
|
||||
t.strong({ className: "label warning" }, oidc.oldHost),
|
||||
t.i({ className: "ri-arrow-right-line txt-sm" }),
|
||||
t.strong({ className: "label success" }, oidc.newHost),
|
||||
t.br(),
|
||||
t.span(
|
||||
{ className: "txt-hint" },
|
||||
"If the old and new OIDC configuration is not for the same provider consider deleting",
|
||||
" all old _externalAuths records associated to the current collection and provider,",
|
||||
" otherwise it may result in account linking errors.",
|
||||
),
|
||||
" ",
|
||||
t.a({
|
||||
rel: "noopenener noreferrer",
|
||||
target: "_blank",
|
||||
href: () => {
|
||||
return `#/collections?collection=_externalAuths&filter=collectionRef%3D%22${newCollection?.id}%22+%26%26+provider%3D%22${oidc.name}%22`;
|
||||
},
|
||||
textContent: "Review existing _externalAuths records",
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
yesCallback,
|
||||
noCallback,
|
||||
{ className: "collection-changes-confirm-modal" },
|
||||
);
|
||||
};
|
||||
82
ui/src/collections/collectionFieldsTab.js
Normal file
82
ui/src/collections/collectionFieldsTab.js
Normal file
@@ -0,0 +1,82 @@
|
||||
export function collectionFieldsTab(upsertData) {
|
||||
return t.div(
|
||||
{ className: "collection-tab-content collection-fields-tab-content" },
|
||||
t.div(
|
||||
{ className: "collection-fields-list" },
|
||||
app.components.sortable({
|
||||
handle: ".sort-handle",
|
||||
data: () => (upsertData.collection?.fields || [])?.filter((f) => !!app.fieldTypes[f.type]?.settings),
|
||||
dataItem: (field, _) => {
|
||||
return app.fieldTypes[field.type].settings({
|
||||
field: field,
|
||||
get originalCollection() {
|
||||
return upsertData.originalCollection;
|
||||
},
|
||||
get collection() {
|
||||
return upsertData.collection;
|
||||
},
|
||||
get originalField() {
|
||||
return upsertData.originalCollection?.fields?.find((f) => field.id && f.id == field.id);
|
||||
},
|
||||
get fieldIndex() {
|
||||
return upsertData.collection.fields?.findIndex((f) =>
|
||||
field.id ? f.id == field.id : f == field
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
onchange: (sortedList, fromIndex, toIndex) => {
|
||||
upsertData.collection.fields = sortedList;
|
||||
},
|
||||
}),
|
||||
),
|
||||
() => app.components.addCollectionFieldButton(upsertData.collection),
|
||||
// indexes
|
||||
t.hr(),
|
||||
t.p(
|
||||
{ className: "txt-bold" },
|
||||
"Unique constraints and indexes (",
|
||||
() => upsertData.collection.indexes?.length,
|
||||
")",
|
||||
),
|
||||
app.components.sortable({
|
||||
className: "indexes-list",
|
||||
data: () => upsertData.collection.indexes || [],
|
||||
onchange: function(sortedList) {
|
||||
upsertData.collection.indexes = sortedList;
|
||||
},
|
||||
dataItem: (index, i) => {
|
||||
const parsed = app.utils.parseIndex(index);
|
||||
|
||||
return t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: () => {
|
||||
const errMsg = app.store.errors?.indexes?.[i]?.message || "";
|
||||
return `label handle ${errMsg ? "danger error" : "success"}`;
|
||||
},
|
||||
ariaDescription: app.attrs.tooltip(() => app.store.errors?.indexes?.[i]?.message || ""),
|
||||
onclick: () => app.modals.openIndexUpsert(upsertData.collection, index),
|
||||
},
|
||||
() => {
|
||||
if (parsed.unique) {
|
||||
return t.strong(null, "Unique:");
|
||||
}
|
||||
},
|
||||
t.span({ className: "txt" }, () => parsed.columns?.map((c) => c.name).join(", ")),
|
||||
);
|
||||
},
|
||||
after: () => {
|
||||
return t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "label handle",
|
||||
onclick: () => app.modals.openIndexUpsert(upsertData.collection),
|
||||
},
|
||||
t.i({ className: "ri-add-line" }),
|
||||
t.span({ className: "txt" }, "New index"),
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
274
ui/src/collections/collectionRulesTab.js
Normal file
274
ui/src/collections/collectionRulesTab.js
Normal file
@@ -0,0 +1,274 @@
|
||||
export function collectionRulesTab(upsertData) {
|
||||
const local = store({
|
||||
showRulesInfo: false,
|
||||
showAuthRules: false,
|
||||
});
|
||||
|
||||
const systemRuleTooltip = () =>
|
||||
app.attrs.tooltip(
|
||||
upsertData.originalCollection?.system ? "System collection rule cannot be changed." : null,
|
||||
"top-left",
|
||||
);
|
||||
|
||||
function autocomplete(word) {
|
||||
return app.utils.collectionAutocompleteKeys(upsertData.collection, word);
|
||||
}
|
||||
|
||||
return t.div(
|
||||
{ className: "collection-tab-content collection-rules-tab-content" },
|
||||
t.div(
|
||||
{ className: "grid" },
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "flex txt-hint txt-sm" },
|
||||
t.span(
|
||||
{ className: "txt" },
|
||||
"All rules follow the ",
|
||||
t.a({
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
href: import.meta.env.PB_RULES_SYNTAX_DOCS,
|
||||
textContent: "PocketBase filter syntax and operators",
|
||||
}),
|
||||
".",
|
||||
),
|
||||
t.strong({
|
||||
tabIndex: -1,
|
||||
className: "m-l-auto link-hint",
|
||||
textContent: () => (local.showRulesInfo ? "Hide available fields" : "Show available fields"),
|
||||
onclick: () => (local.showRulesInfo = !local.showRulesInfo),
|
||||
}),
|
||||
),
|
||||
app.components.slide(
|
||||
() => local.showRulesInfo,
|
||||
t.div(
|
||||
{ className: "alert warning m-t-sm" },
|
||||
t.div(
|
||||
{ className: "content" },
|
||||
t.p(null, "The following record fields are available:"),
|
||||
t.div({ className: "flex flex-wrap gap-5" }, () => {
|
||||
const identifiers = app.utils.getAllCollectionIdentifiers(upsertData.collection);
|
||||
return identifiers.map((f) => {
|
||||
return t.code(null, f);
|
||||
});
|
||||
}),
|
||||
t.hr({ className: "m-t-10 m-b-10" }),
|
||||
t.p(
|
||||
null,
|
||||
"The request fields could be accessed with the special ",
|
||||
t.strong(null, "@request"),
|
||||
" fields:",
|
||||
),
|
||||
t.div(
|
||||
{ className: "flex flex-wrap gap-5" },
|
||||
t.code(null, "@request.headers.*"),
|
||||
t.code(null, "@request.query.*"),
|
||||
t.code(null, "@request.body.*"),
|
||||
t.code(null, "@request.auth.*"),
|
||||
),
|
||||
t.hr({ className: "m-t-10 m-b-10" }),
|
||||
t.p(
|
||||
null,
|
||||
"You could also add constraints and query other collections using the ",
|
||||
t.strong(null, "@collection"),
|
||||
" field:",
|
||||
),
|
||||
t.div(
|
||||
{ className: "flex flex-wrap gap-5" },
|
||||
t.code(null, "@collection.ANY_COLLECTION_NAME.*"),
|
||||
),
|
||||
t.hr({ className: "m-t-10 m-b-10" }),
|
||||
t.p(null, "Example rule:"),
|
||||
() => {
|
||||
const dateField = upsertData.collection.fields?.find(
|
||||
(f) => f.type == "date" || f.type == "autodate",
|
||||
);
|
||||
if (dateField) {
|
||||
return t.code(
|
||||
null,
|
||||
`@request.auth.id != "" && ${dateField.name} > "2022-01-01 00:00:00.000Z"`,
|
||||
);
|
||||
}
|
||||
return t.code(null, `@request.auth.id != ""`);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12", ariaDescription: systemRuleTooltip() },
|
||||
app.components.ruleField({
|
||||
label: "List/Search rule",
|
||||
name: "listRule",
|
||||
autocomplete: autocomplete,
|
||||
disabled: () => upsertData.originalCollection?.system,
|
||||
value: () => upsertData.collection.listRule,
|
||||
oninput: (val) => (upsertData.collection.listRule = val),
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12", ariaDescription: systemRuleTooltip() },
|
||||
app.components.ruleField({
|
||||
label: "View rule",
|
||||
name: "viewRule",
|
||||
autocomplete: autocomplete,
|
||||
disabled: () => upsertData.originalCollection?.system,
|
||||
value: () => upsertData.collection.viewRule,
|
||||
oninput: (val) => (upsertData.collection.viewRule = val),
|
||||
}),
|
||||
),
|
||||
() => {
|
||||
// view collections has only List and View API rules
|
||||
if (upsertData.collection.type == "view") {
|
||||
return;
|
||||
}
|
||||
|
||||
return [
|
||||
t.div(
|
||||
{ className: "col-12", ariaDescription: systemRuleTooltip() },
|
||||
app.components.ruleField({
|
||||
label: [
|
||||
t.span({ className: "txt", textContent: "Create rule" }),
|
||||
t.i({
|
||||
hidden: () => upsertData.collection.createRule == null,
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip(
|
||||
"The main record fields hold the values that are going to be inserted in the database.",
|
||||
),
|
||||
}),
|
||||
],
|
||||
name: "createRule",
|
||||
autocomplete: autocomplete,
|
||||
disabled: () => upsertData.originalCollection?.system,
|
||||
value: () => upsertData.collection.createRule,
|
||||
oninput: (val) => (upsertData.collection.createRule = val),
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12", ariaDescription: systemRuleTooltip() },
|
||||
app.components.ruleField({
|
||||
label: [
|
||||
t.span({ className: "txt", textContent: "Update rule" }),
|
||||
t.i({
|
||||
hidden: () => upsertData.collection.updateRule == null,
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip(
|
||||
"The main record fields hold the old/existing record field values.\nTo target the newly submitted ones you can use @request.body.*.",
|
||||
),
|
||||
}),
|
||||
],
|
||||
name: "updateRule",
|
||||
autocomplete: autocomplete,
|
||||
disabled: () => upsertData.originalCollection?.system,
|
||||
value: () => upsertData.collection.updateRule,
|
||||
oninput: (val) => (upsertData.collection.updateRule = val),
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12", ariaDescription: systemRuleTooltip() },
|
||||
app.components.ruleField({
|
||||
label: "Delete rule",
|
||||
name: "deleteRule",
|
||||
autocomplete: autocomplete,
|
||||
disabled: () => upsertData.originalCollection?.system,
|
||||
value: () => upsertData.collection.deleteRule,
|
||||
oninput: (val) => (upsertData.collection.deleteRule = val),
|
||||
}),
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
// auth specific fields
|
||||
() => {
|
||||
if (upsertData.collection.type != "auth") {
|
||||
return;
|
||||
}
|
||||
|
||||
return [
|
||||
t.hr({ className: "m-t-base m-b-base" }),
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
onmount: () => {
|
||||
local.showAuthRules = upsertData.collection.manageRule !== null
|
||||
|| upsertData.collection.authRule !== "";
|
||||
},
|
||||
className: () => `btn secondary sm ${local.showAuthRules ? "" : "transparent"}`,
|
||||
onclick: () => {
|
||||
local.showAuthRules = !local.showAuthRules;
|
||||
},
|
||||
},
|
||||
t.span({ className: "txt" }, "Additional auth collection rules"),
|
||||
t.i({
|
||||
className: () => (local.showAuthRules ? "ri-arrow-drop-up-line" : "ri-arrow-drop-down-line"),
|
||||
}),
|
||||
),
|
||||
app.components.slide(
|
||||
() => local.showAuthRules,
|
||||
t.div(
|
||||
{ className: "grid sm m-t-sm" },
|
||||
t.div(
|
||||
{ className: "col-12", ariaDescription: systemRuleTooltip() },
|
||||
app.components.ruleField({
|
||||
label: "Authentication rule",
|
||||
name: "authRule",
|
||||
placeholder: "",
|
||||
autocomplete: autocomplete,
|
||||
disabled: () => upsertData.originalCollection?.system,
|
||||
value: () => upsertData.collection.authRule,
|
||||
oninput: (val) => (upsertData.collection.authRule = val),
|
||||
}),
|
||||
t.div(
|
||||
{ className: "field-help" },
|
||||
t.p(
|
||||
null,
|
||||
"This rule is executed every time ",
|
||||
t.strong(null, "before authentication"),
|
||||
" allowing you to restrict who can authenticate.",
|
||||
),
|
||||
t.p(
|
||||
null,
|
||||
"For example, to allow only verified users you can set it to ",
|
||||
t.code(null, "verified = true"),
|
||||
".",
|
||||
),
|
||||
t.p(null, "Leave it empty to allow anyone with an account to authenticate."),
|
||||
t.p(
|
||||
null,
|
||||
`To disable authentication entirely you can change it to "Set superusers only".`,
|
||||
),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12", ariaDescription: systemRuleTooltip() },
|
||||
app.components.ruleField({
|
||||
label: "Manage rule",
|
||||
name: "manageRule",
|
||||
autocomplete: autocomplete,
|
||||
disabled: () => upsertData.originalCollection?.system,
|
||||
value: () => upsertData.collection.manageRule,
|
||||
oninput: (val) => (upsertData.collection.manageRule = val),
|
||||
}),
|
||||
t.div(
|
||||
{ className: "field-help" },
|
||||
t.p(
|
||||
null,
|
||||
"This rule is executed in addition to the ",
|
||||
t.strong(null, "create"),
|
||||
" and ",
|
||||
t.strong(null, "update"),
|
||||
" API rules.",
|
||||
),
|
||||
t.p(
|
||||
null,
|
||||
"It enables superuser-like permissions to allow fully managing the auth record(s), eg. changing the password without requiring to enter the old one, directly updating the verified state or email, etc.",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
);
|
||||
}
|
||||
913
ui/src/collections/collectionUpsertModal.js
Normal file
913
ui/src/collections/collectionUpsertModal.js
Normal file
@@ -0,0 +1,913 @@
|
||||
import { toDeleteProp } from "@/base/fieldSettings";
|
||||
import { collectionAuthOptionsTab } from "./collectionAuthOptionsTab";
|
||||
import { collectionFieldsTab } from "./collectionFieldsTab";
|
||||
import { collectionRulesTab } from "./collectionRulesTab";
|
||||
import { collectionViewQueryTab } from "./collectionViewQueryTab";
|
||||
|
||||
window.app = window.app || {};
|
||||
window.app.modals = window.app.modals || {};
|
||||
|
||||
window.app.modals.openCollectionUpsert = function(collection = {}, modalSettings = {
|
||||
// base modal events
|
||||
onbeforeopen: null, // function(el) {},
|
||||
onafteropen: null, // function(el) {},
|
||||
onbeforeclose: null, // function(el) {},
|
||||
onafterclose: null, // function(el) {},
|
||||
// collection specific events
|
||||
onsave: null, // function(collection, isNew) {},
|
||||
ondelete: null, // function(collection) {},
|
||||
onduplicate: null, // function(collection) {},
|
||||
ontruncate: null, // function(collection) {},
|
||||
}) {
|
||||
app.store.errors = null; // reset
|
||||
|
||||
const modal = collectionUpsertModal(collection || {}, modalSettings || {});
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
app.modals.open(modal);
|
||||
};
|
||||
|
||||
window.app.collectionTypes = {
|
||||
"base": {
|
||||
"icon": "ri-folder-2-line",
|
||||
"tabs": {
|
||||
"Fields": collectionFieldsTab,
|
||||
"API rules": collectionRulesTab,
|
||||
},
|
||||
},
|
||||
"view": {
|
||||
"icon": "ri-table-line",
|
||||
"tabs": {
|
||||
"Query": collectionViewQueryTab,
|
||||
"API rules": collectionRulesTab,
|
||||
},
|
||||
},
|
||||
"auth": {
|
||||
"icon": "ri-group-line",
|
||||
"tabs": {
|
||||
"Fields": collectionFieldsTab,
|
||||
"API rules": collectionRulesTab,
|
||||
"Options": collectionAuthOptionsTab,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function collectionUpsertModal(rawCollection, modalSettings) {
|
||||
let modal;
|
||||
|
||||
const uniqueId = "collection_upsert_" + app.utils.randomString();
|
||||
|
||||
const data = store({
|
||||
isSaving: false,
|
||||
originalCollection: {},
|
||||
collection: {},
|
||||
selectedTab: "",
|
||||
get activeTab() {
|
||||
if (!app.collectionTypes[data.collection.type]?.tabs) {
|
||||
return data.selectedTab;
|
||||
}
|
||||
|
||||
if (
|
||||
!data.selectedTab
|
||||
|| !app.collectionTypes[data.collection.type].tabs?.[data.selectedTab]
|
||||
) {
|
||||
return Object.keys(app.collectionTypes[data.collection.type].tabs)?.[0] || "";
|
||||
}
|
||||
|
||||
return data.selectedTab;
|
||||
},
|
||||
get isNew() {
|
||||
return app.utils.isEmpty(data.originalCollection?.id);
|
||||
},
|
||||
get collectionTypeOptions() {
|
||||
return Object.keys(app.collectionTypes).map((type) => {
|
||||
return {
|
||||
value: type,
|
||||
label: app.utils.sentenize(type, false) + " collection",
|
||||
};
|
||||
});
|
||||
},
|
||||
get collectionHash() {
|
||||
Object.keys(data.collection).length;
|
||||
return JSON.stringify(data.collection);
|
||||
},
|
||||
get originalCollectionHash() {
|
||||
return JSON.stringify(data.originalCollection);
|
||||
},
|
||||
get hasChanges() {
|
||||
return data.originalCollectionHash != data.collectionHash;
|
||||
},
|
||||
get canSave() {
|
||||
return !data.isSaving && (data.isNew || data.hasChanges);
|
||||
},
|
||||
});
|
||||
|
||||
async function initCollection(collection) {
|
||||
if (app.utils.isEmpty(collection)) {
|
||||
collection = JSON.parse(JSON.stringify(app.store.collectionScaffolds.base)) || {
|
||||
type: "base",
|
||||
fields: [],
|
||||
};
|
||||
|
||||
// add commonly used timestamp fields
|
||||
collection.fields.push({
|
||||
type: "autodate",
|
||||
name: "created",
|
||||
onCreate: true,
|
||||
});
|
||||
collection.fields.push({
|
||||
type: "autodate",
|
||||
name: "updated",
|
||||
onCreate: true,
|
||||
onUpdate: true,
|
||||
});
|
||||
}
|
||||
|
||||
data.originalCollection = JSON.parse(JSON.stringify(collection));
|
||||
data.collection = JSON.parse(JSON.stringify(collection));
|
||||
}
|
||||
|
||||
async function confirmSave(close = true) {
|
||||
if (!data.canSave) {
|
||||
return;
|
||||
}
|
||||
|
||||
data.isSaving = true;
|
||||
|
||||
app.modals.openCollectionChangesConfirmation(
|
||||
data.originalCollection,
|
||||
data.collection,
|
||||
() => save(close),
|
||||
() => {
|
||||
data.isSaving = false;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function exportPayload() {
|
||||
const payload = JSON.parse(JSON.stringify(data.collection));
|
||||
payload.fields = payload.fields || [];
|
||||
|
||||
// remove fields marked for deletion
|
||||
for (let i = payload.fields.length - 1; i >= 0; i--) {
|
||||
const field = payload.fields[i];
|
||||
|
||||
if (field[toDeleteProp]) {
|
||||
payload.fields.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function save(close = true) {
|
||||
data.isSaving = true;
|
||||
|
||||
try {
|
||||
const payload = exportPayload();
|
||||
|
||||
const isNew = app.utils.isEmpty(data.originalCollection?.id);
|
||||
|
||||
let request;
|
||||
if (isNew) {
|
||||
request = app.pb.collections.create(payload);
|
||||
} else {
|
||||
request = app.pb.collections.update(data.originalCollection.id, payload);
|
||||
}
|
||||
|
||||
const rawCollection = JSON.stringify(await request);
|
||||
|
||||
data.originalCollection = JSON.parse(rawCollection);
|
||||
data.collection = JSON.parse(rawCollection);
|
||||
app.store.addOrUpdateCollection(JSON.parse(rawCollection));
|
||||
|
||||
modalSettings?.onsave?.(JSON.parse(rawCollection), isNew);
|
||||
|
||||
data.isSaving = false;
|
||||
|
||||
app.toasts.success(
|
||||
isNew
|
||||
? `Successfully created collection "${data.collection.name}".`
|
||||
: `Successfully updated collection "${data.collection.name}".`,
|
||||
{ key: "collectionSave" },
|
||||
);
|
||||
|
||||
// reset all errors
|
||||
app.store.errors = null;
|
||||
|
||||
if (close) {
|
||||
app.modals.close(modal, true);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!err?.isAbort) {
|
||||
data.isSaving = false;
|
||||
app.checkApiError(err, false);
|
||||
app.toasts.error(err.message || "Failed to save collection.", { key: "collectionSave" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
data.collection = JSON.parse(JSON.stringify(data.originalCollection));
|
||||
}
|
||||
|
||||
async function duplicate() {
|
||||
const clone = data.originalCollection ? JSON.parse(JSON.stringify(data.originalCollection)) : {};
|
||||
clone.id = "";
|
||||
clone.system = false;
|
||||
clone.name = clone.name + "_duplicate";
|
||||
clone.created = "";
|
||||
clone.updated = "";
|
||||
|
||||
// updated indexes ids
|
||||
clone.indexes = clone.indexes?.map((idx) => {
|
||||
return app.utils.replaceIndexFields(idx, (parsed) => {
|
||||
return {
|
||||
indexName: parsed.indexName + app.utils.randomString(3),
|
||||
tableName: clone.name,
|
||||
};
|
||||
});
|
||||
}) || [];
|
||||
|
||||
await modalSettings.onduplicate?.(clone);
|
||||
|
||||
return initCollection(clone);
|
||||
}
|
||||
|
||||
async function changeTab(tabName) {
|
||||
data.selectedTab = tabName;
|
||||
|
||||
// ui tick
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
// refresh errors in case to retrigger validations
|
||||
if (app.store.errors) {
|
||||
app.store.errors = JSON.parse(JSON.stringify(app.store.errors));
|
||||
}
|
||||
}
|
||||
|
||||
modal = t.div(
|
||||
{
|
||||
pbEvent: "collectionUpsertModal",
|
||||
"html-data-collectionId": () => data.originalCollection?.id,
|
||||
"html-data-collectionName": () => data.originalCollection?.name,
|
||||
className: "modal collection-upsert-modal",
|
||||
inert: () => data.isSaving,
|
||||
onkeydown: (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.code == "KeyS") {
|
||||
e.preventDefault();
|
||||
// temp blur any active input to make sure that onchange/blur events are fired
|
||||
const input = document.activeElement;
|
||||
input?.blur();
|
||||
|
||||
confirmSave(false);
|
||||
|
||||
// restore previous active input
|
||||
input?.focus();
|
||||
}
|
||||
},
|
||||
onbeforeopen: () => {
|
||||
initCollection(rawCollection);
|
||||
|
||||
return modalSettings.onbeforeopen?.(el);
|
||||
},
|
||||
onafteropen: (el) => {
|
||||
modalSettings.onafteropen?.(el);
|
||||
},
|
||||
onbeforeclose: (el, forceClosed) => {
|
||||
if (forceClosed) {
|
||||
return modalSettings.onbeforeclose?.(el);
|
||||
}
|
||||
|
||||
if (data.isSaving) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!data.hasChanges) {
|
||||
return modalSettings.onbeforeclose?.(el);
|
||||
}
|
||||
|
||||
return new Promise((r) => {
|
||||
app.modals.confirm(
|
||||
"You have unsaved changes. Do you really want to discard them?",
|
||||
() => r(modalSettings.onbeforeclose?.(el)),
|
||||
() => r(false),
|
||||
);
|
||||
});
|
||||
},
|
||||
onafterclose: (el) => {
|
||||
modalSettings.onafterclose?.(el);
|
||||
el?.remove();
|
||||
},
|
||||
onmount: (el) => {
|
||||
el._watchers?.forEach((w) => w?.unwatch());
|
||||
el._watchers = [
|
||||
watch(
|
||||
() => data.collection.type,
|
||||
(newType, oldType) => {
|
||||
if (!oldType || newType == oldType || !app.store.collectionScaffolds[newType]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// reset fields list errors on type change
|
||||
app.utils.deleteByPath(app.store.errors, "fields");
|
||||
|
||||
// merge with the scaffold to ensure that the minimal props are set
|
||||
const scaffold = JSON.parse(JSON.stringify(app.store.collectionScaffolds[newType]));
|
||||
data.collection = Object.assign(
|
||||
structuredClone(scaffold),
|
||||
JSON.parse(JSON.stringify(data.collection)),
|
||||
);
|
||||
data.originalCollection = scaffold;
|
||||
syncFieldsAndIndexesWithScaffold(data.collection);
|
||||
},
|
||||
),
|
||||
|
||||
// collection rename
|
||||
watch(
|
||||
() => data.collection.name,
|
||||
(newName, oldName) => {
|
||||
newName = app.utils.slugify(newName);
|
||||
data.collection.name = newName;
|
||||
|
||||
if (typeof oldName == "undefined" || !newName || newName == oldName) {
|
||||
return;
|
||||
}
|
||||
|
||||
// update indexes with the latest collection name as table name
|
||||
clearTimeout(el.__collectionRenameTimeoutId);
|
||||
el.__collectionRenameTimeoutId = setTimeout(() => {
|
||||
data.collection.indexes = data.collection.indexes?.map((idx) => {
|
||||
return app.utils.replaceIndexFields(idx, { tableName: data.collection.name });
|
||||
});
|
||||
}, 150);
|
||||
},
|
||||
),
|
||||
];
|
||||
},
|
||||
onunmount: (el) => {
|
||||
clearTimeout(el?.__collectionRenameTimeoutId);
|
||||
el?._watchers?.forEach((w) => w?.unwatch());
|
||||
},
|
||||
},
|
||||
t.header(
|
||||
{ className: "modal-header isolated" },
|
||||
t.div(
|
||||
{ className: "grid sm" },
|
||||
t.div(
|
||||
{ className: "col-12 flex" },
|
||||
t.h6(
|
||||
{ className: "modal-title" },
|
||||
t.span(null, () => (data.isNew ? "Create " : "Edit ")),
|
||||
t.strong(
|
||||
{
|
||||
hidden: () => data.isNew,
|
||||
className: "txt-ellipsis collection-name",
|
||||
},
|
||||
() => data.originalCollection?.name,
|
||||
),
|
||||
t.span(null, " collection"),
|
||||
),
|
||||
t.div({ className: "flex-fill" }),
|
||||
() => {
|
||||
if (app.utils.isEmpty(data.originalCollection?.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return [
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "btn sm circle transparent",
|
||||
"html-popovertarget": uniqueId + "modal-header-dropdown",
|
||||
},
|
||||
t.i({ className: "ri-more-line" }),
|
||||
),
|
||||
t.div(
|
||||
{
|
||||
id: uniqueId + "modal-header-dropdown",
|
||||
className: "dropdown nowrap modal-header-dropdown",
|
||||
popover: "auto",
|
||||
},
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "dropdown-item",
|
||||
onclick: (e) => {
|
||||
e.target.closest(".dropdown").hidePopover();
|
||||
app.utils.copyToClipboard(
|
||||
JSON.stringify(data.originalCollection, null, 2),
|
||||
);
|
||||
app.toasts.success("Collection copied to clipboard!");
|
||||
},
|
||||
},
|
||||
t.i({ className: "ri-braces-line" }),
|
||||
t.span({ className: "txt" }, "Copy JSON"),
|
||||
),
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "dropdown-item",
|
||||
onclick: (e) => {
|
||||
e.target.closest(".dropdown").hidePopover();
|
||||
|
||||
if (data.hasChanges) {
|
||||
app.modals.confirm(
|
||||
"You have unsaved changes. Do you really want to discard them?",
|
||||
duplicate,
|
||||
null,
|
||||
{ yesButton: "Yes, discard" },
|
||||
);
|
||||
} else {
|
||||
duplicate();
|
||||
}
|
||||
},
|
||||
},
|
||||
t.i({ className: "ri-file-copy-line" }),
|
||||
t.span({ className: "txt" }, "Duplicate"),
|
||||
),
|
||||
t.hr(),
|
||||
() => {
|
||||
if (data.collection.type == "view") {
|
||||
return; // view don't have their own records
|
||||
}
|
||||
return truncateDropdownItem(data, modalSettings);
|
||||
},
|
||||
() => {
|
||||
if (data.collection.system) {
|
||||
return; // system collections cannot be deleted
|
||||
}
|
||||
return deleteDropdownItem(data, modalSettings);
|
||||
},
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "fields" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({
|
||||
htmlFor: uniqueId + "col_name",
|
||||
textContent: () => {
|
||||
return `Name${data.collection?.system ? " (system)" : ""}`;
|
||||
},
|
||||
}),
|
||||
t.input({
|
||||
id: uniqueId + "col_name",
|
||||
type: "text",
|
||||
name: "name",
|
||||
required: true,
|
||||
spellcheck: false,
|
||||
placeholder: "e.g. posts",
|
||||
autofocus: () => data.isNew,
|
||||
disabled: () => !data.isNew && data.collection?.system,
|
||||
value: () => data.collection.name || "",
|
||||
onmount: (el) => {
|
||||
el.addEventListener("compositionend", (e) => {
|
||||
data.collection.name = e.target.value;
|
||||
});
|
||||
},
|
||||
oninput: (e) => {
|
||||
if (e.isComposing) {
|
||||
return;
|
||||
}
|
||||
data.collection.name = e.target.value;
|
||||
},
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{ className: "field addon" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
disabled: () => !data.isNew,
|
||||
className: () =>
|
||||
`btn sm collection-type-select ${data.isNew ? "outline" : "transparent"}`,
|
||||
"html-popovertarget": uniqueId + "col_type_dropdown",
|
||||
},
|
||||
t.span(
|
||||
{ className: "txt" },
|
||||
"Type: ",
|
||||
() => app.utils.sentenize(data.collection.type, false) || "N/A",
|
||||
),
|
||||
t.i({
|
||||
hidden: () => !data.isNew,
|
||||
className: "ri-arrow-drop-down-line m-l-auto",
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{
|
||||
id: uniqueId + "col_type_dropdown",
|
||||
className: "dropdown nowrap collection-type-dropdown",
|
||||
popover: "auto",
|
||||
},
|
||||
() => {
|
||||
let options = [];
|
||||
|
||||
for (const opt of data.collectionTypeOptions) {
|
||||
options.push(
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: () =>
|
||||
`dropdown-item ${
|
||||
opt.value == data.collection.type ? "active" : ""
|
||||
}`,
|
||||
onclick: (e) => {
|
||||
e.target.closest(".dropdown").hidePopover();
|
||||
data.collection.type = opt.value;
|
||||
},
|
||||
},
|
||||
t.i({
|
||||
className: app.collectionTypes[opt.value]?.icon
|
||||
|| app.utils.fallbackCollectionIcon,
|
||||
}),
|
||||
t.span({ className: "txt" }, opt.label || opt.value),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return options;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.nav(
|
||||
{ className: "tabs-header equal-width" },
|
||||
() => {
|
||||
const tabItems = [];
|
||||
|
||||
const tabs = app.collectionTypes[data.collection.type]?.tabs || {};
|
||||
for (let tabName in tabs) {
|
||||
tabItems.push(
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
disabled: () => data.isSaving,
|
||||
className: () => `tab-item ${data.activeTab == tabName ? "active" : ""}`,
|
||||
onclick: () => changeTab(tabName),
|
||||
},
|
||||
t.span({ className: "txt" }, tabName),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return tabItems;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "modal-content" },
|
||||
() => app.collectionTypes[data.collection.type]?.tabs?.[data.activeTab]?.(data),
|
||||
),
|
||||
t.footer(
|
||||
{ className: "modal-footer" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "btn transparent m-r-auto",
|
||||
disabled: () => data.isSaving,
|
||||
onclick: () => app.modals.close(modal),
|
||||
},
|
||||
t.span({ className: "txt" }, "Close"),
|
||||
),
|
||||
() => {
|
||||
const rawErrors = JSON.stringify(app.store.errors);
|
||||
if (rawErrors == "" || rawErrors == "null" || rawErrors == "{}" || rawErrors == "[]") {
|
||||
return;
|
||||
}
|
||||
|
||||
return t.i({
|
||||
className: "ri-alert-line txt-danger",
|
||||
ariaDescription: app.attrs.tooltip(() => "Raw error:\n" + rawErrors),
|
||||
});
|
||||
},
|
||||
t.div(
|
||||
{ className: "btns" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: () => `btn expanded-lg ${data.isSaving ? "loading" : ""}`,
|
||||
disabled: () => !data.canSave,
|
||||
onclick: () => confirmSave(true),
|
||||
},
|
||||
t.span({ className: "txt" }, () => (data.isNew ? "Create" : "Save changes")),
|
||||
),
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: () => `btn p-5`,
|
||||
disabled: () => !data.canSave,
|
||||
"html-popovertarget": uniqueId + "save_options",
|
||||
},
|
||||
t.i({ className: "ri-arrow-up-s-line" }),
|
||||
),
|
||||
t.div(
|
||||
{ id: uniqueId + "save_options", className: "dropdown nowrap", popover: "auto" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "dropdown-item",
|
||||
onclick: (e) => {
|
||||
e.target.closest(".dropdown").hidePopover();
|
||||
confirmSave(false);
|
||||
},
|
||||
},
|
||||
t.span({ className: "txt" }, "Save and continue"),
|
||||
t.small({ className: "txt-hint" }, "(Ctrl+S)"),
|
||||
),
|
||||
t.hr(),
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "dropdown-item",
|
||||
onclick: (e) => {
|
||||
e.target.closest(".dropdown").hidePopover();
|
||||
resetForm();
|
||||
},
|
||||
},
|
||||
t.span({ className: "txt" }, "Reset form"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return modal;
|
||||
}
|
||||
|
||||
function syncFieldsAndIndexesWithScaffold(collection) {
|
||||
const newScaffold = JSON.parse(JSON.stringify(app.store.collectionScaffolds[collection.type]));
|
||||
|
||||
// merge fields
|
||||
// -----------------------------------------------------------
|
||||
const oldFields = JSON.parse(JSON.stringify(collection.fields)) || [];
|
||||
const nonSystemFields = oldFields.filter((f) => !f.system);
|
||||
|
||||
collection.fields = newScaffold.fields || [];
|
||||
|
||||
for (const oldField of oldFields) {
|
||||
if (!oldField.system) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const field = collection.fields.find((f) => f.name == oldField.name);
|
||||
if (!field) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// merge the default field with the existing one
|
||||
Object.assign(field, oldField);
|
||||
}
|
||||
|
||||
for (const field of nonSystemFields) {
|
||||
collection.fields.push(field);
|
||||
}
|
||||
|
||||
// merge indexes
|
||||
// -----------------------------------------------------------
|
||||
collection.indexes = collection.indexes || [];
|
||||
|
||||
if (collection.indexes.length) {
|
||||
const scaffoldIndexes = newScaffold?.indexes || [];
|
||||
|
||||
indexesLoop: for (let i = collection.indexes.length - 1; i >= 0; i--) {
|
||||
const parsed = app.utils.parseIndex(collection.indexes[i]);
|
||||
const parsedName = parsed.indexName.toLowerCase();
|
||||
|
||||
// remove old scaffold indexes
|
||||
for (const idx of scaffoldIndexes) {
|
||||
const oldScaffoldName = app.utils.parseIndex(idx).indexName.toLowerCase();
|
||||
if (parsedName == oldScaffoldName) {
|
||||
collection.indexes.splice(i, 1);
|
||||
continue indexesLoop;
|
||||
}
|
||||
}
|
||||
|
||||
// remove indexes to nonexisting fields
|
||||
for (const column of parsed.columns) {
|
||||
const hasFieldWithName = !!collection.fields.find(
|
||||
(f) => f.name.toLowerCase() == column.name.toLowerCase(),
|
||||
);
|
||||
if (!hasFieldWithName) {
|
||||
collection.indexes.splice(i, 1);
|
||||
continue indexesLoop;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// merge new scaffold indexes
|
||||
app.utils.mergeUnique(collection.indexes, newScaffold.indexes);
|
||||
}
|
||||
|
||||
function truncateDropdownItem(data, modalSettings) {
|
||||
const uniqueId = "truncate_" + app.utils.randomString();
|
||||
|
||||
const local = store({
|
||||
isSubmitting: false,
|
||||
nameConfirm: "",
|
||||
});
|
||||
|
||||
async function truncateCollection() {
|
||||
if (
|
||||
local.isSubmitting
|
||||
|| !data.originalCollection?.name
|
||||
|| data.originalCollection.name != local.nameConfirm
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
local.isSubmitting = true;
|
||||
|
||||
try {
|
||||
await app.pb.collections.truncate(data.originalCollection.name);
|
||||
|
||||
modalSettings.ontruncate?.(JSON.parse(JSON.stringify(data.originalCollection)));
|
||||
|
||||
app.toasts.success(`Successfully truncated collection "${data.originalCollection.name}".`);
|
||||
|
||||
local.isSubmitting = false;
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
local.isSubmitting = false;
|
||||
app.checkApiError(err);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "dropdown-item txt-danger",
|
||||
disabled: () => local.isSubmitting,
|
||||
onclick: (e) => {
|
||||
e.target.closest(".dropdown").hidePopover();
|
||||
app.modals.confirm(
|
||||
t.div(
|
||||
null,
|
||||
t.h6(
|
||||
{ className: "block txt-center" },
|
||||
"Do you really want to delete all records of the collection?",
|
||||
),
|
||||
t.div(
|
||||
{ className: "confirm-collection-label txt-bold m-t-sm m-b-sm" },
|
||||
"Type the collection name ",
|
||||
t.div(
|
||||
{ className: "label" },
|
||||
() => data.originalCollection.name,
|
||||
app.components.copyButton(() => data.originalCollection?.name),
|
||||
),
|
||||
" to confirm:",
|
||||
),
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".confirm_name" }, "Collection name"),
|
||||
t.input({
|
||||
id: uniqueId + ".confirm_name",
|
||||
type: "text",
|
||||
required: true,
|
||||
pattern: () => RegExp.escape(data.originalCollection.name),
|
||||
value: () => local.nameConfirm,
|
||||
oninput: (e) => local.nameConfirm = e.target.value,
|
||||
}),
|
||||
),
|
||||
),
|
||||
async () => {
|
||||
document.getElementById(uniqueId + ".confirm_name")?.reportValidity();
|
||||
|
||||
const truncated = await truncateCollection();
|
||||
if (!truncated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
app.modals.close(e.target.closest(".modal"));
|
||||
},
|
||||
() => {
|
||||
local.nameConfirm = "";
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
t.i({ className: "ri-eraser-line" }),
|
||||
t.span({ className: "txt" }, "Truncate"),
|
||||
);
|
||||
}
|
||||
|
||||
function deleteDropdownItem(data, modalSettings) {
|
||||
const uniqueId = "delete_" + app.utils.randomString();
|
||||
|
||||
const local = store({
|
||||
isSubmitting: false,
|
||||
nameConfirm: "",
|
||||
});
|
||||
|
||||
async function deleteCollection() {
|
||||
if (
|
||||
local.isSubmitting
|
||||
|| !data.originalCollection?.name
|
||||
|| data.originalCollection.name != local.nameConfirm
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
local.isSubmitting = true;
|
||||
|
||||
try {
|
||||
await app.pb.collections.delete(data.originalCollection.name);
|
||||
|
||||
modalSettings.ondelete?.(JSON.parse(JSON.stringify(data.originalCollection)));
|
||||
|
||||
app.utils.removeByKey(app.store.collections, "id", data.originalCollection.id);
|
||||
|
||||
app.toasts.success(`Successfully deleted collection "${data.originalCollection.name}".`);
|
||||
|
||||
local.isSubmitting = false;
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
local.isSubmitting = false;
|
||||
app.checkApiError(err);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "dropdown-item txt-danger",
|
||||
disabled: () => local.isSubmitting,
|
||||
onclick: (e) => {
|
||||
e.target.closest(".dropdown").hidePopover();
|
||||
|
||||
const collectionModal = e.target.closest(".modal");
|
||||
|
||||
app.modals.confirm(
|
||||
t.div(
|
||||
{ className: "block" },
|
||||
t.h6(
|
||||
{ className: "block txt-center" },
|
||||
() => {
|
||||
if (data.originalCollection.type == "view") {
|
||||
return "Do you really want to delete the selected collection?";
|
||||
}
|
||||
|
||||
return "Do you really want to delete the selected collection and all its records";
|
||||
},
|
||||
),
|
||||
t.div(
|
||||
{ className: "confirm-collection-label txt-bold m-t-sm m-b-sm" },
|
||||
"Type the collection name ",
|
||||
t.div(
|
||||
{ className: "label" },
|
||||
() => data.originalCollection.name,
|
||||
app.components.copyButton(() => data.originalCollection?.name),
|
||||
),
|
||||
" to confirm:",
|
||||
),
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".confirm_name" }, "Collection name"),
|
||||
t.input({
|
||||
id: uniqueId + ".confirm_name",
|
||||
type: "text",
|
||||
required: true,
|
||||
pattern: () => RegExp.escape(data.originalCollection.name),
|
||||
value: () => local.nameConfirm,
|
||||
oninput: (e) => local.nameConfirm = e.target.value,
|
||||
}),
|
||||
),
|
||||
),
|
||||
async () => {
|
||||
document.getElementById(uniqueId + ".confirm_name")?.reportValidity();
|
||||
|
||||
const deleted = await deleteCollection();
|
||||
if (!deleted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
app.modals.close(collectionModal);
|
||||
},
|
||||
() => {
|
||||
local.nameConfirm = "";
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
t.i({ className: "ri-delete-bin-7-line" }),
|
||||
t.span({ className: "txt" }, "Delete"),
|
||||
);
|
||||
}
|
||||
192
ui/src/collections/collectionViewQueryTab.js
Normal file
192
ui/src/collections/collectionViewQueryTab.js
Normal file
@@ -0,0 +1,192 @@
|
||||
const TEST_REQUEST_KEY = "test_view_query";
|
||||
|
||||
export function collectionViewQueryTab(upsertData) {
|
||||
const uniqueId = "query_" + app.utils.randomString();
|
||||
|
||||
// dprint-ignore
|
||||
const autocomplete = [
|
||||
"SELECT", "FROM", "WHERE", "LEFT JOIN", "INNER JOIN", "ON",
|
||||
"GROUP BY", "HAVING", "ORDER BY", "LIMIT", "OFFSET", "AS",
|
||||
"WITH", "NOT", "IN", "EXISTS", "LIKE", "CAST",
|
||||
];
|
||||
|
||||
const local = store({
|
||||
testRecords: [],
|
||||
testError: "",
|
||||
isTesting: false,
|
||||
});
|
||||
|
||||
async function dryRunViewQuery(query) {
|
||||
local.isTesting = true;
|
||||
|
||||
local.testRecords = [];
|
||||
|
||||
// reset form errors related to the query
|
||||
if (app.store.errors?.viewQuery || app.store.errors?.fields) {
|
||||
delete app.store.errors.viewQuery;
|
||||
delete app.store.errors.fields;
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
local.testError = "";
|
||||
local.isTesting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// @todo replace with SDK method
|
||||
const result = await app.pb.send("/api/collections/meta/dry-run-view", {
|
||||
method: "POST",
|
||||
body: { "query": query },
|
||||
requestKey: TEST_REQUEST_KEY,
|
||||
});
|
||||
|
||||
if (upsertData.collection?.id) {
|
||||
// replace the collection meta fields
|
||||
local.testRecords = result.sample.map((r) => {
|
||||
r.collectionId = upsertData.collection?.id;
|
||||
r.collectionName = upsertData.collection?.name;
|
||||
return r;
|
||||
});
|
||||
} else {
|
||||
local.testRecords = result.sample;
|
||||
}
|
||||
|
||||
local.testError = "";
|
||||
local.isTesting = false;
|
||||
} catch (err) {
|
||||
if (!err.isAbort) {
|
||||
local.testError = err.message || "Invalid query.";
|
||||
local.isTesting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let testDebounceId;
|
||||
|
||||
const watchers = [
|
||||
watch(() => upsertData.collection?.viewQuery, (newQuery) => {
|
||||
clearTimeout(testDebounceId);
|
||||
testDebounceId = setTimeout(() => dryRunViewQuery(newQuery), 200);
|
||||
}),
|
||||
];
|
||||
|
||||
return t.div(
|
||||
{
|
||||
pbEvent: "collectionViewQueryTabContent",
|
||||
className: "collection-tab-content collection-view-query-tab-content",
|
||||
onunmount: () => {
|
||||
clearTimeout(testDebounceId);
|
||||
app.pb.cancelRequest(TEST_REQUEST_KEY);
|
||||
watchers.forEach((w) => w?.unwatch());
|
||||
},
|
||||
},
|
||||
t.div(
|
||||
{ className: "grid" },
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "txt-right txt-sm m-b-10" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "txt-bold link-hint",
|
||||
"html-popovertarget": uniqueId + "caveats_dropdown",
|
||||
},
|
||||
() => "Query caveats",
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{
|
||||
id: uniqueId + "caveats_dropdown",
|
||||
className: "dropdown sm query-caveats-dropdown",
|
||||
popover: "auto",
|
||||
},
|
||||
t.ul(
|
||||
null,
|
||||
t.li(null, "Wildcard columns (*) are not supported."),
|
||||
t.li(
|
||||
null,
|
||||
"The query must have a unique ",
|
||||
t.code(null, "id"),
|
||||
" column.",
|
||||
t.br(),
|
||||
"If your query doesn't have a suitable one, you can use the universal ",
|
||||
t.code(null, "(ROW_NUMBER() OVER()) as id"),
|
||||
".",
|
||||
),
|
||||
t.li(
|
||||
null,
|
||||
"Expressions must be aliased with a valid formatted field name, e.g. ",
|
||||
t.code(null, "MAX(balance) as maxBalance"),
|
||||
".",
|
||||
),
|
||||
t.li(
|
||||
null,
|
||||
"Combined/multi-spaced expressions must be wrapped in parenthesis, e.g. ",
|
||||
t.code(null, "(MAX(balance) + 1) as maxBalance"),
|
||||
".",
|
||||
),
|
||||
t.li(
|
||||
null,
|
||||
"UNION expressions are supported but the entire query must be wrapped in parenthesis.",
|
||||
),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId + ".viewQuery" },
|
||||
t.span({ className: "txt" }, "Select query"),
|
||||
t.span(
|
||||
{
|
||||
hidden: () => !local.testError,
|
||||
className: "query-state",
|
||||
ariaDescription: app.attrs.tooltip("Invalid query", "left"),
|
||||
},
|
||||
t.i({ className: "ri-error-warning-fill txt-danger" }),
|
||||
),
|
||||
t.span(
|
||||
{
|
||||
hidden: () => !!local.testError,
|
||||
className: "query-state",
|
||||
ariaDescription: app.attrs.tooltip("Valid query", "left"),
|
||||
},
|
||||
t.i({ className: "ri-checkbox-circle-fill txt-success" }),
|
||||
),
|
||||
),
|
||||
app.components.codeEditor({
|
||||
id: uniqueId + ".viewQuery",
|
||||
name: "viewQuery",
|
||||
language: "sql",
|
||||
required: true,
|
||||
autocomplete: autocomplete,
|
||||
className: "inline-error",
|
||||
value: () => upsertData.collection.viewQuery || "",
|
||||
oninput: (newVal) => {
|
||||
upsertData.collection.viewQuery = newVal;
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.p({ className: "txt-sm txt-bold" }, "Sample output:"),
|
||||
t.div(
|
||||
{ className: "view-query-sample-wrapper" },
|
||||
app.components.codeBlock({
|
||||
language: () => local.testError ? "plain" : "js",
|
||||
className: () => `view-query-sample ${local.testError ? "txt-danger" : ""}`,
|
||||
value: () => {
|
||||
if (local.testRecords?.length) {
|
||||
return JSON.stringify(local.testRecords, null, 2);
|
||||
}
|
||||
|
||||
return local.testError || "N/A";
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
282
ui/src/collections/collectionsOverviewModal.js
Normal file
282
ui/src/collections/collectionsOverviewModal.js
Normal file
@@ -0,0 +1,282 @@
|
||||
window.app = window.app || {};
|
||||
window.app.modals = window.app.modals || {};
|
||||
|
||||
window.app.modals.openCollectionsOverview = function(settings = {
|
||||
onbeforeopen: null,
|
||||
onafteropen: null,
|
||||
onbeforeclose: null,
|
||||
onafterclose: null,
|
||||
}) {
|
||||
const modal = collectionsOverviewModal(settings);
|
||||
if (!modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.appendChild(modal);
|
||||
app.modals.open(modal);
|
||||
};
|
||||
|
||||
function collectionsOverviewModal(settings = {}) {
|
||||
const uniqueId = "overview_modal_" + app.utils.randomString();
|
||||
|
||||
const tabs = {
|
||||
"Fields and relations": erd,
|
||||
"Rules": rules,
|
||||
};
|
||||
|
||||
const data = store({
|
||||
showSystemCollections: false,
|
||||
activeTab: Object.keys(tabs)[0],
|
||||
get collections() {
|
||||
if (data.showSystemCollections) {
|
||||
return app.store.collections;
|
||||
}
|
||||
|
||||
return app.store.collections.filter((c) => !c.system);
|
||||
},
|
||||
});
|
||||
|
||||
const modal = t.div(
|
||||
{
|
||||
pbEvent: "collectionsOverviewModal",
|
||||
className: "modal popup collections-overview-modal",
|
||||
onbeforeopen: (el) => {
|
||||
return settings.onbeforeopen?.(el);
|
||||
},
|
||||
onafteropen: (el) => {
|
||||
settings.onafteropen?.(el);
|
||||
},
|
||||
onbeforeclose: (el) => {
|
||||
return settings.onbeforeclose?.(el);
|
||||
},
|
||||
onafterclose: (el) => {
|
||||
settings.onafterclose?.(el);
|
||||
el?.remove();
|
||||
},
|
||||
},
|
||||
t.header(
|
||||
{ className: "modal-header isolated" },
|
||||
t.div(
|
||||
{ className: "grid sm" },
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "flex" },
|
||||
t.h6({ className: "modal-title" }, "Collections overview"),
|
||||
t.div({ className: "flex-fill" }),
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.input({
|
||||
id: uniqueId + ".showSystemCollections",
|
||||
type: "checkbox",
|
||||
className: "sm switch",
|
||||
checked: () => data.showSystemCollections,
|
||||
onchange: (e) => data.showSystemCollections = e.target.checked,
|
||||
}),
|
||||
t.label({ htmlFor: uniqueId + ".showSystemCollections" }, "System collections"),
|
||||
),
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "btn sm secondary transparent circle modal-close-btn",
|
||||
onclick: () => app.modals.close(modal),
|
||||
},
|
||||
t.i({ className: "ri-close-line" }),
|
||||
),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "tabs-header equal-width" },
|
||||
() => {
|
||||
const items = [];
|
||||
|
||||
for (let title in tabs) {
|
||||
items.push(t.button({
|
||||
type: "button",
|
||||
className: () => `tab-item ${data.activeTab == title ? "active" : ""}`,
|
||||
onclick: () => data.activeTab = title,
|
||||
textContent: title,
|
||||
}));
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
() => {
|
||||
return tabs[data.activeTab]?.(data);
|
||||
},
|
||||
);
|
||||
|
||||
return modal;
|
||||
}
|
||||
|
||||
function erd(data) {
|
||||
return t.div(
|
||||
{ className: "modal-content erd-tab" },
|
||||
app.components.erd({
|
||||
collections: () => {
|
||||
let underscoreA, underscoreB;
|
||||
function sortSystemUnderscoredLast(a, b) {
|
||||
underscoreA = a.name.startsWith("_");
|
||||
underscoreB = b.name.startsWith("_");
|
||||
if (
|
||||
(a.system && !b.system)
|
||||
|| (underscoreA && !underscoreB)
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (
|
||||
(!a.system && b.system)
|
||||
|| (!underscoreA && underscoreB)
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
return data.collections.slice().sort(sortSystemUnderscoredLast);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function rules(data) {
|
||||
const ruleOptions = [
|
||||
{ value: "listRule", label: "List/Search rule" },
|
||||
{ value: "viewRule", label: "View rule" },
|
||||
{ value: "createRule", label: "Create rule", filter: (c) => c.type != "view" },
|
||||
{ value: "updateRule", label: "Update rule", filter: (c) => c.type != "view" },
|
||||
{ value: "deleteRule", label: "Delete rule", filter: (c) => c.type != "view" },
|
||||
{ value: "authRule", label: "Auth rule", filter: (c) => c.type == "auth" },
|
||||
{ value: "manageRule", label: "Manage rule", filter: (c) => c.type == "auth" },
|
||||
{
|
||||
value: "mfaRule",
|
||||
label: "MFA rule",
|
||||
emptyLabel: t.span({ className: "label info" }, "Enabled for everyone"),
|
||||
rule: (c) => c.mfa?.rule,
|
||||
filter: (c) => c.mfa?.enabled && c.type == "auth",
|
||||
},
|
||||
];
|
||||
|
||||
const local = store({
|
||||
activeRuleOption: ruleOptions[0],
|
||||
get activeCollections() {
|
||||
if (!local.activeRuleOption.filter) {
|
||||
return data.collections;
|
||||
}
|
||||
|
||||
return data.collections.filter((c) => local.activeRuleOption.filter(c));
|
||||
},
|
||||
});
|
||||
|
||||
return t.div(
|
||||
{ className: "modal-content rules-tab" },
|
||||
t.table(
|
||||
{ className: "rules-table" },
|
||||
t.thead(
|
||||
{ className: "sticky" },
|
||||
t.tr(
|
||||
null,
|
||||
t.td(
|
||||
{ colSpan: 99, className: "col-rule-btns" },
|
||||
t.div(
|
||||
{ className: "rule-btns" },
|
||||
() => {
|
||||
return ruleOptions.map((opt) => {
|
||||
return t.button({
|
||||
type: "button",
|
||||
className: () =>
|
||||
`btn sm ${
|
||||
local.activeRuleOption?.value == opt.value
|
||||
? "outline"
|
||||
: "transparent secondary"
|
||||
}`,
|
||||
textContent: () => opt.label,
|
||||
onclick: () => local.activeRuleOption = opt,
|
||||
});
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
t.tbody(
|
||||
null,
|
||||
() => {
|
||||
if (!local.activeCollections.length) {
|
||||
return t.tr(
|
||||
null,
|
||||
t.td(
|
||||
{ colSpan: 99, className: "txt-hint" },
|
||||
t.p(null, "No collections with the selected rule."),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return local.activeCollections.map((collection) => {
|
||||
return t.tr(
|
||||
null,
|
||||
t.th(
|
||||
{ className: "min-width" },
|
||||
t.div(
|
||||
{ className: "inline-flex gap-10" },
|
||||
t.i({
|
||||
className: () =>
|
||||
app.collectionTypes[collection.type]?.icon
|
||||
|| app.utils.fallbackCollectionIcon,
|
||||
}),
|
||||
t.span({
|
||||
className: "txt collection-name",
|
||||
title: () => collection.name,
|
||||
textContent: () => collection.name,
|
||||
}),
|
||||
),
|
||||
),
|
||||
() => {
|
||||
let rule;
|
||||
if (local.activeRuleOption.rule) {
|
||||
rule = local.activeRuleOption.rule(collection);
|
||||
} else {
|
||||
rule = collection[local.activeRuleOption.value];
|
||||
}
|
||||
|
||||
return t.td(
|
||||
{ style: "vertical-align: top" },
|
||||
() => {
|
||||
if (rule === null) {
|
||||
if (local.activeRuleOption.nullLabel) {
|
||||
return local.activeRuleOption.nullLabel;
|
||||
}
|
||||
|
||||
return t.span({ className: "label success" }, "Superusers only");
|
||||
}
|
||||
|
||||
if (rule === "") {
|
||||
if (local.activeRuleOption.emptyLabel) {
|
||||
return local.activeRuleOption.emptyLabel;
|
||||
}
|
||||
|
||||
return t.span({ className: "label info" }, "Public");
|
||||
}
|
||||
|
||||
return app.components.codeBlock({
|
||||
language: "pbrule",
|
||||
value: rule,
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
258
ui/src/collections/collectionsSidebar.js
Normal file
258
ui/src/collections/collectionsSidebar.js
Normal file
@@ -0,0 +1,258 @@
|
||||
const PINNED_STORAGE_KEY = "pbPinnedCollections";
|
||||
|
||||
const compactThreshold = 12;
|
||||
|
||||
export function collectionsSidebar() {
|
||||
const data = store({
|
||||
search: "",
|
||||
pinned: app.utils.getLocalHistory(PINNED_STORAGE_KEY, []),
|
||||
get filteredCollections() {
|
||||
if (!data.search.length) {
|
||||
return app.store.collections;
|
||||
}
|
||||
|
||||
const normalizedSearch = data.search.replaceAll(" ", "").toLowerCase();
|
||||
|
||||
return app.store.collections.filter((c) => {
|
||||
return (c.name + c.id + c.type).toLowerCase().includes(normalizedSearch);
|
||||
});
|
||||
},
|
||||
get systemCollections() {
|
||||
return data.filteredCollections.filter((c) => c.system && !data.pinned.includes(c.id));
|
||||
},
|
||||
get regularCollections() {
|
||||
return data.filteredCollections.filter((c) => !c.system && !data.pinned.includes(c.id));
|
||||
},
|
||||
get pinnedCollections() {
|
||||
if (!data.pinned.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.filteredCollections.filter((c) => data.pinned.includes(c.id));
|
||||
},
|
||||
});
|
||||
|
||||
function clearSearch() {
|
||||
data.search = "";
|
||||
}
|
||||
|
||||
const watchers = [];
|
||||
|
||||
return app.components.pageSidebar(
|
||||
{
|
||||
className: () => `collections-sidebar ${data.responsiveShow ? "active" : ""}`,
|
||||
onmount: (el) => {
|
||||
// init and persist pinned changes
|
||||
watchers.push(watch(() => {
|
||||
app.utils.saveLocalHistory(PINNED_STORAGE_KEY, JSON.stringify(data.pinned));
|
||||
}));
|
||||
|
||||
// scroll to the active item
|
||||
watchers.push(watch(
|
||||
() => app.store.activeCollection?.id,
|
||||
async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const activeNavItem = el?.querySelector(".nav-item.active");
|
||||
const details = activeNavItem?.closest("details");
|
||||
if (details) {
|
||||
details.open = true;
|
||||
activeNavItem?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
},
|
||||
));
|
||||
},
|
||||
onunmount: () => {
|
||||
watchers.forEach((w) => w?.unwatch());
|
||||
},
|
||||
},
|
||||
t.div(
|
||||
{ className: "sidebar-search" },
|
||||
t.div(
|
||||
{ className: "fields" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.input({
|
||||
className: "p-r-5",
|
||||
type: "text",
|
||||
placeholder: "Search collections...",
|
||||
value: () => data.search,
|
||||
oninput: (e) => data.search = e.target.value,
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{ className: "field addon p-l-0 p-r-5 gap-0" },
|
||||
t.button(
|
||||
{
|
||||
hidden: () => !data.search.length,
|
||||
type: "button",
|
||||
className: "btn sm circle transparent secondary",
|
||||
ariaDescription: app.attrs.tooltip("Clear", "left"),
|
||||
onclick: clearSearch,
|
||||
},
|
||||
t.i({ className: "ri-close-line", ariaHidden: true }),
|
||||
),
|
||||
t.button(
|
||||
{
|
||||
hidden: () => app.store.isLoadingCollections,
|
||||
type: "button",
|
||||
className: "btn sm circle transparent secondary link-faded",
|
||||
ariaDescription: app.attrs.tooltip("Collections overview", "left"),
|
||||
onclick: () => app.modals.openCollectionsOverview(),
|
||||
},
|
||||
t.i({ className: "ri-organization-chart", ariaHidden: true }),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
() => {
|
||||
if (
|
||||
!data.search.length
|
||||
|| !!data.filteredCollections.length
|
||||
|| app.store.isLoadingCollections
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return t.div(
|
||||
{ className: "block p-t-base txt-center txt-hint" },
|
||||
t.p(null, "No collections found."),
|
||||
t.button({
|
||||
type: "button",
|
||||
className: "btn sm secondary",
|
||||
textContent: "Clear search",
|
||||
onclick: () => clearSearch(),
|
||||
}),
|
||||
);
|
||||
},
|
||||
() => {
|
||||
if (app.store.isLoadingCollections) {
|
||||
return t.div({ className: "sidebar-content txt-center" }, t.span({ className: "loader sm" }));
|
||||
}
|
||||
|
||||
return [
|
||||
t.nav(
|
||||
{
|
||||
className: () =>
|
||||
`sidebar-content collections-list scrollable ${
|
||||
data.regularCollections.length + data.pinnedCollections >= compactThreshold
|
||||
? "compact"
|
||||
: ""
|
||||
}`,
|
||||
},
|
||||
t.details(
|
||||
{
|
||||
hidden: () => !data.pinnedCollections.length,
|
||||
className: () => `nav-group nav-group-pinned-collections`,
|
||||
open: true,
|
||||
},
|
||||
t.summary(
|
||||
{ tabIndex: -1, onfocusout: () => false, onclick: () => false, onkeyup: () => false },
|
||||
"Pinned",
|
||||
),
|
||||
() => data.pinnedCollections.map((c) => collectionItem(c, data)),
|
||||
),
|
||||
t.details(
|
||||
{
|
||||
hidden: () => !data.regularCollections.length,
|
||||
className: "nav-group nav-group-regular-collections",
|
||||
open: true,
|
||||
},
|
||||
t.summary(
|
||||
{ tabIndex: -1, onfocusout: () => false, onclick: () => false, onkeyup: () => false },
|
||||
() => data.pinnedCollections.length ? "Others" : "Collections",
|
||||
),
|
||||
() => data.regularCollections.map((c) => collectionItem(c, data)),
|
||||
),
|
||||
t.details(
|
||||
{
|
||||
hidden: () => !data.systemCollections.length,
|
||||
className: "nav-group nav-group-system-collections",
|
||||
open: () => data.search.length,
|
||||
},
|
||||
t.summary(null, "System"),
|
||||
() => data.systemCollections.map((c) => collectionItem(c, data)),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{
|
||||
hidden: () => data.search.length && !data.filteredCollections.length,
|
||||
className: "sidebar-content new-collection",
|
||||
},
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "btn outline block",
|
||||
onclick: () => {
|
||||
app.modals.openCollectionUpsert({}, {
|
||||
onsave: (newCollection) => {
|
||||
app.store.activeCollection = newCollection.id;
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
t.i({ className: "ri-add-line" }),
|
||||
t.span({ textContent: "New collection" }),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function collectionItem(collection, data) {
|
||||
return t.button(
|
||||
{
|
||||
"html-data-collection-id": () => collection.id,
|
||||
type: "button",
|
||||
className: () =>
|
||||
`nav-item responsive-close ${collection.id == app.store.activeCollection?.id ? "active" : ""}`,
|
||||
title: () => collection.name,
|
||||
onclick: () => app.store.activeCollection = collection.name,
|
||||
},
|
||||
t.i({ className: () => app.collectionTypes[collection.type]?.icon || app.utils.fallbackCollectionIcon }),
|
||||
t.span({ className: "txt" }, () => collection.name),
|
||||
() => {
|
||||
if (
|
||||
collection.type != "auth"
|
||||
|| !collection.oauth2?.enabled
|
||||
|| collection.oauth2?.providers?.length > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return t.i({
|
||||
ariaHidden: true,
|
||||
className: "ri-alert-line txt-hint txt-sm",
|
||||
ariaDescription: app.attrs.tooltip(
|
||||
"OAuth2 auth is enabled but the collection doesn't have any registered providers",
|
||||
),
|
||||
});
|
||||
},
|
||||
() => {
|
||||
const pinnedIndex = data.pinned.indexOf(collection.id);
|
||||
|
||||
return t.span(
|
||||
{
|
||||
tabIndex: -1,
|
||||
role: "button",
|
||||
className: "pin",
|
||||
title: () => pinnedIndex >= 0 ? "Unpin" : "Pin",
|
||||
onclick: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (pinnedIndex >= 0) {
|
||||
data.pinned.splice(pinnedIndex, 1);
|
||||
} else {
|
||||
data.pinned.push(collection.id);
|
||||
}
|
||||
},
|
||||
},
|
||||
t.i({
|
||||
ariaHidden: false,
|
||||
className: () => pinnedIndex >= 0 ? "ri-unpin-line" : "ri-pushpin-line",
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
112
ui/src/collections/emailTemplateAccordion.js
Normal file
112
ui/src/collections/emailTemplateAccordion.js
Normal file
@@ -0,0 +1,112 @@
|
||||
export function emailTemplateAccordion(collection, key, propsArg = {}) {
|
||||
const uniqueId = "emailTemplate" + app.utils.randomString();
|
||||
|
||||
const props = store({
|
||||
title: "Email template",
|
||||
placeholders: [],
|
||||
});
|
||||
|
||||
const watchers = app.utils.extendStore(props, propsArg);
|
||||
|
||||
const data = store({
|
||||
get config() {
|
||||
let val = app.utils.getByPath(collection, key);
|
||||
|
||||
if (!val) {
|
||||
val = { subject: "", body: "" };
|
||||
app.utils.setByPath(collection, key, val);
|
||||
}
|
||||
|
||||
return val;
|
||||
},
|
||||
get tokensList() {
|
||||
return [];
|
||||
},
|
||||
});
|
||||
|
||||
const placeholdersList = () => {
|
||||
if (!props.placeholders?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return t.div(
|
||||
{ className: "field-help" },
|
||||
t.div({ className: "flex flex-wrap gap-5" }, t.span({ className: "txt" }, "Placeholders:"), () => {
|
||||
return props.placeholders.map((p) => {
|
||||
return t.span({ className: "label sm" }, app.components.copyButton(p, p));
|
||||
});
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return t.details(
|
||||
{
|
||||
pbEvent: "emailTemplateAccordion",
|
||||
className: "accordion email-template-accordion",
|
||||
name: "email-template",
|
||||
onunmount: () => {
|
||||
watchers.forEach((w) => w?.unwatch());
|
||||
},
|
||||
},
|
||||
t.summary(
|
||||
null,
|
||||
t.i({ className: "ri-draft-line" }),
|
||||
t.span({ className: "txt", textContent: () => props.title }),
|
||||
() => {
|
||||
if (!app.utils.getByPath(app.store.errors, key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return t.i({
|
||||
className: "ri-error-warning-fill txt-danger m-l-auto",
|
||||
ariaDescription: app.attrs.tooltip("Has errors", "left"),
|
||||
});
|
||||
},
|
||||
),
|
||||
t.div(
|
||||
{ className: "grid" },
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({
|
||||
htmlFor: uniqueId + ".subject",
|
||||
textContent: "Subject",
|
||||
}),
|
||||
app.components.codeEditor({
|
||||
id: uniqueId + ".subject",
|
||||
name: key + ".subject",
|
||||
required: true,
|
||||
singleLine: true,
|
||||
language: "text",
|
||||
autocomplete: props.placeholders,
|
||||
value: () => data.config.subject || "",
|
||||
oninput: (val) => (data.config.subject = val),
|
||||
}),
|
||||
),
|
||||
placeholdersList,
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({
|
||||
htmlFor: uniqueId + ".body",
|
||||
textContent: "Body (HTML)",
|
||||
}),
|
||||
app.components.codeEditor({
|
||||
id: uniqueId + ".body",
|
||||
name: key + ".body",
|
||||
required: true,
|
||||
language: "html",
|
||||
className: "pre-wrap",
|
||||
autocomplete: props.placeholders,
|
||||
value: () => data.config.body || "",
|
||||
oninput: (val) => (data.config.body = val),
|
||||
}),
|
||||
),
|
||||
placeholdersList,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
273
ui/src/collections/indexUpsertModal.js
Normal file
273
ui/src/collections/indexUpsertModal.js
Normal file
@@ -0,0 +1,273 @@
|
||||
window.app = window.app || {};
|
||||
window.app.modals = window.app.modals || {};
|
||||
|
||||
window.app.modals.openIndexUpsert = function(
|
||||
collection,
|
||||
index = "",
|
||||
settings = {
|
||||
onsave: () => {},
|
||||
ondelete: () => {},
|
||||
},
|
||||
) {
|
||||
const modal = indexUpsertModal(collection, index, settings);
|
||||
if (!modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.appendChild(modal);
|
||||
app.modals.open(modal);
|
||||
};
|
||||
|
||||
function indexUpsertModal(collection, index = "", settings = {}) {
|
||||
if (!collection) {
|
||||
console.warn("[indexUpsertModal] missing required collection argument");
|
||||
return;
|
||||
}
|
||||
|
||||
let modal;
|
||||
|
||||
const uniqueId = app.utils.randomString();
|
||||
|
||||
const data = store({
|
||||
originalIndex: "",
|
||||
index: "",
|
||||
get isNew() {
|
||||
return data.originalIndex == "";
|
||||
},
|
||||
get indexParts() {
|
||||
return app.utils.parseIndex(data.index);
|
||||
},
|
||||
get lowerCasedIndexColumnNames() {
|
||||
return data.indexParts.columns.map((c) => c.name.toLowerCase());
|
||||
},
|
||||
get canSave() {
|
||||
return data.lowerCasedIndexColumnNames.length > 0;
|
||||
},
|
||||
});
|
||||
|
||||
const presetColumns = collection?.fields?.filter((f) => !f["@toDelete"] && f.name != "id")?.map((f) => f.name)
|
||||
|| [];
|
||||
|
||||
function loadIndex(index) {
|
||||
data.originalIndex = index || "";
|
||||
|
||||
if (!index) {
|
||||
const parsed = app.utils.parseIndex("");
|
||||
parsed.tableName = collection?.name || "";
|
||||
index = app.utils.buildIndex(parsed);
|
||||
}
|
||||
|
||||
data.index = index;
|
||||
}
|
||||
|
||||
function saveIndex() {
|
||||
if (!collection || !data.canSave) {
|
||||
console.warn("[saveIndex] no collection or invalid save state:", collection, data.canSave);
|
||||
return;
|
||||
}
|
||||
|
||||
collection.indexes = collection.indexes || [];
|
||||
|
||||
// search for existing
|
||||
const pos = collection.indexes.findIndex((index) => index == data.originalIndex);
|
||||
if (pos >= 0) {
|
||||
// replace
|
||||
collection.indexes[pos] = data.index;
|
||||
app.utils.deleteByPath(app.store.errors, "indexes." + pos);
|
||||
} else {
|
||||
// push missing
|
||||
collection.indexes.push(data.index);
|
||||
}
|
||||
|
||||
if (typeof settings?.onsave == "function") {
|
||||
settings.onsave({
|
||||
collection: collection,
|
||||
index: data.index,
|
||||
oldIndex: data.originalIndex,
|
||||
});
|
||||
}
|
||||
|
||||
clearIndexError();
|
||||
|
||||
app.modals.close(modal);
|
||||
}
|
||||
|
||||
function deleteIndex() {
|
||||
if (!collection || !data.originalIndex) {
|
||||
console.warn("[deleteIndex] no collection or index:", collection, data.originalIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
const pos = collection.indexes?.findIndex((index) => index == data.originalIndex);
|
||||
if (pos == -1) {
|
||||
console.warn("[deleteIndex] missing index:", data.originalIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
collection.indexes.splice(pos, 1);
|
||||
app.utils.deleteByPath(app.store.errors, "indexes." + pos);
|
||||
|
||||
if (typeof settings?.ondelete == "function") {
|
||||
settings.ondelete({
|
||||
collection: collection,
|
||||
position: pos,
|
||||
index: data.originalIndex,
|
||||
});
|
||||
}
|
||||
|
||||
clearIndexError();
|
||||
|
||||
app.modals.close(modal);
|
||||
}
|
||||
|
||||
function toggleColumn(column) {
|
||||
const clone = JSON.parse(JSON.stringify(data.indexParts));
|
||||
clone.tableName = collection?.name || "";
|
||||
|
||||
const colLowerCased = column.toLowerCase();
|
||||
|
||||
const i = clone.columns.findIndex((c) => c.name.toLowerCase() == colLowerCased);
|
||||
if (i >= 0) {
|
||||
clone.columns.splice(i, 1);
|
||||
} else {
|
||||
app.utils.pushUnique(clone.columns, { name: column });
|
||||
}
|
||||
|
||||
data.index = app.utils.buildIndex(clone);
|
||||
|
||||
clearIndexError();
|
||||
}
|
||||
|
||||
function clearIndexError() {
|
||||
if (app.store.errors?.indexes) {
|
||||
const pos = collection.indexes.findIndex((idx) => idx == data.originalIndex);
|
||||
app.utils.deleteByPath(app.store.errors, "indexes." + pos);
|
||||
}
|
||||
}
|
||||
|
||||
modal = t.div(
|
||||
{
|
||||
className: "modal popup index-upsert-modal",
|
||||
onbeforeopen: () => {
|
||||
loadIndex(index);
|
||||
},
|
||||
onafteropen: () => {
|
||||
// retrigger indexes error (if any)
|
||||
if (app.store.errors?.indexes) {
|
||||
app.store.errors.indexes = JSON.parse(JSON.stringify(app.store.errors.indexes));
|
||||
}
|
||||
},
|
||||
onafterclose: (el) => {
|
||||
el?.remove();
|
||||
},
|
||||
},
|
||||
t.header(
|
||||
{ className: "modal-header" },
|
||||
t.h6(
|
||||
{ className: "modal-title" },
|
||||
t.span({ className: "txt" }, () => (data.isNew ? "Create index" : "Update index")),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "modal-content" },
|
||||
t.form(
|
||||
{
|
||||
id: uniqueId + "form",
|
||||
className: "grid sm index-upsert-form",
|
||||
onsubmit: (e) => {
|
||||
e.preventDefault();
|
||||
saveIndex();
|
||||
},
|
||||
},
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.input({
|
||||
type: "checkbox",
|
||||
className: "switch",
|
||||
id: uniqueId + "checkbox_unique",
|
||||
checked: () => data.indexParts.unique,
|
||||
onchange: (e) => {
|
||||
const newIndexParts = JSON.parse(JSON.stringify(data.indexParts));
|
||||
newIndexParts.unique = e.target.checked;
|
||||
newIndexParts.tableName = newIndexParts.tableName || collection?.name || "";
|
||||
data.index = app.utils.buildIndex(newIndexParts);
|
||||
},
|
||||
}),
|
||||
t.label({ htmlFor: uniqueId + "checkbox_unique" }, "Unique"),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
app.components.codeEditor({
|
||||
required: true,
|
||||
className: "collection-index-input pre-wrap",
|
||||
name: () => "indexes." + collection.indexes?.findIndex((idx) => idx == data.originalIndex),
|
||||
placeholder: () => `e.g. CREATE INDEX idx_test on ${collection?.name || "X"} (created)`,
|
||||
value: () => data.index,
|
||||
oninput: (val) => (data.index = val),
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{ hidden: () => !presetColumns.length, className: "field-help m-t-sm" },
|
||||
t.div(
|
||||
{ className: "flex flex-wrap gap-5" },
|
||||
t.span({ className: "txt", textContent: "Presets:" }),
|
||||
() => {
|
||||
return presetColumns?.map((col) => {
|
||||
const isSelected = data.lowerCasedIndexColumnNames.includes(col.toLowerCase());
|
||||
return t.button({
|
||||
type: "button",
|
||||
textContent: col,
|
||||
className: () => `label handle ${isSelected ? "success" : ""}`,
|
||||
onclick: () => toggleColumn(col),
|
||||
});
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
t.footer(
|
||||
{ className: "modal-footer gap-base" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "btn transparent m-r-auto",
|
||||
onclick: () => app.modals.close(modal),
|
||||
},
|
||||
t.span({ className: "txt" }, "Close"),
|
||||
),
|
||||
t.button(
|
||||
{
|
||||
hidden: () => data.isNew,
|
||||
type: "button",
|
||||
className: () => "btn sm circle transparent secondary",
|
||||
ariaDescription: app.attrs.tooltip("Delete index", "left"),
|
||||
onclick: () => {
|
||||
app.modals.confirm(
|
||||
"Do you really want to remove the selected index from the collection?",
|
||||
deleteIndex,
|
||||
);
|
||||
},
|
||||
},
|
||||
t.i({ className: "ri-delete-bin-7-line" }),
|
||||
),
|
||||
t.button(
|
||||
{
|
||||
"type": "submit",
|
||||
"html-form": uniqueId + "form",
|
||||
"disabled": () => !data.canSave,
|
||||
"className": () => "btn expanded",
|
||||
},
|
||||
t.span({ className: "txt" }, "Set index"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return modal;
|
||||
}
|
||||
131
ui/src/collections/mfaAccordion.js
Normal file
131
ui/src/collections/mfaAccordion.js
Normal file
@@ -0,0 +1,131 @@
|
||||
export function mfaAccordion(collection) {
|
||||
const uniqueId = "mfa_" + app.utils.randomString();
|
||||
|
||||
const data = store({
|
||||
get config() {
|
||||
if (!collection.mfa) {
|
||||
collection.mfa = {
|
||||
enabled: false,
|
||||
duration: 900,
|
||||
rule: "",
|
||||
};
|
||||
}
|
||||
|
||||
return collection.mfa;
|
||||
},
|
||||
});
|
||||
|
||||
return t.details(
|
||||
{
|
||||
pbEvent: "mfaAccordion",
|
||||
name: "auth-methods",
|
||||
className: "accordion mfa-accordion",
|
||||
},
|
||||
t.summary(
|
||||
null,
|
||||
t.i({ className: "ri-shield-check-line" }),
|
||||
t.span({ className: "txt", textContent: "Multi-factor authentication (MFA)" }),
|
||||
t.span({
|
||||
className: () => `label m-l-auto ${data.config.enabled ? "success" : ""}`,
|
||||
textContent: () => (data.config.enabled ? "Enabled" : "Disabled"),
|
||||
}),
|
||||
() => {
|
||||
if (!app.store.errors?.mfa) {
|
||||
return;
|
||||
}
|
||||
|
||||
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-sm-12" },
|
||||
t.div(
|
||||
{ className: "alert info" },
|
||||
t.div(
|
||||
{ className: "content" },
|
||||
t.p(
|
||||
null,
|
||||
"Multi-factor authentication (MFA) requires the user to authenticate with any 2 different auth methods (otp, identity/password, oauth2) before issuing an auth token. ",
|
||||
t.a({
|
||||
href: import.meta.env.PB_MFA_DOCS,
|
||||
className: "link-hint",
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
textContent: "Learn more.",
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.input({
|
||||
type: "checkbox",
|
||||
id: uniqueId + ".enabled",
|
||||
name: "mfa.enabled",
|
||||
className: "switch",
|
||||
checked: () => data.config.enabled,
|
||||
onchange: (e) => (data.config.enabled = e.target.checked),
|
||||
}),
|
||||
t.label({
|
||||
htmlFor: uniqueId + ".enabled",
|
||||
textContent: "Enable",
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({
|
||||
htmlFor: uniqueId + ".duration",
|
||||
textContent: "Max duration between 2 authentications (in seconds)",
|
||||
}),
|
||||
t.input({
|
||||
type: "number",
|
||||
id: uniqueId + ".duration",
|
||||
name: "mfa.duration",
|
||||
min: 1,
|
||||
step: 1,
|
||||
required: true,
|
||||
value: () => data.config.duration || "",
|
||||
oninput: (e) => (data.config.duration = parseInt(e.target.value, 10)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-12" },
|
||||
app.components.ruleField({
|
||||
label: "MFA rule",
|
||||
id: uniqueId + ".rule",
|
||||
name: "mfa.rule",
|
||||
nullable: false,
|
||||
placeholder: "Leave empty to require MFA for everyone",
|
||||
autocomplete: (word) => {
|
||||
return app.utils.collectionAutocompleteKeys(collection, word);
|
||||
},
|
||||
value: () => data.config.rule || "",
|
||||
oninput: (newVal) => (data.config.rule = newVal),
|
||||
}),
|
||||
t.div(
|
||||
{ className: "field-help" },
|
||||
t.p(null, "This optional rule could be used to enable/disable MFA per account basis."),
|
||||
t.p(
|
||||
null,
|
||||
"For example, to require MFA only for accounts with non-empty email you can set it to ",
|
||||
t.code(null, "email != ''"),
|
||||
".",
|
||||
),
|
||||
t.p(null, "Leave the rule empty to require MFA for everyone."),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
236
ui/src/collections/oauth2/appleOptions.js
Normal file
236
ui/src/collections/oauth2/appleOptions.js
Normal file
@@ -0,0 +1,236 @@
|
||||
window.app = window.app || {};
|
||||
window.app.oauth2 = window.app.oauth2 || {};
|
||||
|
||||
// note: data is the providerSettingsModal form store
|
||||
window.app.oauth2.apple = function(providerInfo, namePrefix, data) {
|
||||
const uniqueId = "apple_" + app.utils.randomString();
|
||||
|
||||
return t.div(
|
||||
{ pbEvent: "oauth2AppleOptions", className: "oauth2-apple-options" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "btn sm secondary",
|
||||
onclick: () => {
|
||||
app.modals.openAppleSecretGenerator({
|
||||
ongenerate: (secret) => {
|
||||
data.config.clientSecret = secret;
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
t.i({ className: "ri-key-line" }),
|
||||
t.span({ className: "txt" }, "Generate secret"),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
window.app.modals = window.app.modals || {};
|
||||
|
||||
window.app.modals.openAppleSecretGenerator = function(modalSettings = {
|
||||
onbeforeopen: null,
|
||||
onafteropen: null,
|
||||
onbeforeclose: null,
|
||||
onafterclose: null,
|
||||
ongenerate: null, // (secret) => {}
|
||||
}) {
|
||||
const modal = appleSecretGeneratorModal(modalSettings);
|
||||
if (!modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
app.modals.open(modal);
|
||||
};
|
||||
|
||||
function appleSecretGeneratorModal(modalSettings = {}) {
|
||||
let modal;
|
||||
|
||||
const uniqueId = "secret_generator_" + app.utils.randomString();
|
||||
|
||||
const maxDuration = 15777000; // 6 months
|
||||
|
||||
const data = store({
|
||||
clientId: "",
|
||||
teamId: "",
|
||||
keyId: "",
|
||||
privateKey: "",
|
||||
duration: maxDuration,
|
||||
isSubmitting: false,
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
data.isSubmitting = true;
|
||||
|
||||
try {
|
||||
const result = await app.pb.settings.generateAppleClientSecret(
|
||||
data.clientId,
|
||||
data.teamId,
|
||||
data.keyId,
|
||||
data.privateKey.trim(),
|
||||
data.duration,
|
||||
);
|
||||
|
||||
data.isSubmitting = false;
|
||||
|
||||
app.toasts.success("Successfully generated client secret.");
|
||||
|
||||
modalSettings.ongenerate?.(result.secret);
|
||||
|
||||
app.modals.close(modal);
|
||||
} catch (err) {
|
||||
if (!err.isAbort) {
|
||||
app.checkApiError(err);
|
||||
data.isSubmitting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
modal = t.div(
|
||||
{
|
||||
pbEvent: "appleSecretGeneratorModal",
|
||||
className: "modal popup apple-secret-generator-modal",
|
||||
onbeforeopen: (el) => {
|
||||
return modalSettings.onbeforeopen?.(el);
|
||||
},
|
||||
onafteropen: (el) => {
|
||||
modalSettings.onafteropen?.(el);
|
||||
},
|
||||
onbeforeclose: (el) => {
|
||||
return modalSettings.onbeforeclose?.(el);
|
||||
},
|
||||
onafterclose: (el) => {
|
||||
modalSettings.onafterclose?.(el);
|
||||
el?.remove();
|
||||
},
|
||||
},
|
||||
t.header(
|
||||
{ className: "modal-header" },
|
||||
t.h5({ className: "m-auto" }, "Generate Apple client secret"),
|
||||
),
|
||||
t.form(
|
||||
{
|
||||
id: uniqueId + "_form",
|
||||
className: "modal-content",
|
||||
onsubmit: (e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
},
|
||||
},
|
||||
t.div(
|
||||
{ className: "grid" },
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".clientId" }, "Client ID"),
|
||||
t.input({
|
||||
id: uniqueId + ".clientId",
|
||||
name: "clientId",
|
||||
type: "text",
|
||||
required: true,
|
||||
value: () => data.clientId || "",
|
||||
oninput: (e) => data.clientId = e.target.value,
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".teamId" }, "Team ID"),
|
||||
t.input({
|
||||
id: uniqueId + ".teamId",
|
||||
name: "teamId",
|
||||
type: "text",
|
||||
required: true,
|
||||
value: () => data.teamId || "",
|
||||
oninput: (e) => data.teamId = e.target.value,
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".keyId" }, "Key ID"),
|
||||
t.input({
|
||||
id: uniqueId + ".keyId",
|
||||
name: "keyId",
|
||||
type: "text",
|
||||
required: true,
|
||||
value: () => data.keyId || "",
|
||||
oninput: (e) => data.keyId = e.target.value,
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".duration" }, "Duration (in seconds)"),
|
||||
t.input({
|
||||
id: uniqueId + ".duration",
|
||||
name: "duration",
|
||||
type: "number",
|
||||
min: 0,
|
||||
step: 1,
|
||||
max: maxDuration,
|
||||
required: true,
|
||||
value: () => data.duration || 0,
|
||||
oninput: (e) => data.duration = parseInt(e.target.value, 10),
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{ className: "field-help" },
|
||||
`Max ${maxDuration} seconds (~${(maxDuration / (60 * 60 * 24 * 30)) << 0} months).`,
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".privateKey" }, "Private key"),
|
||||
t.textarea({
|
||||
id: uniqueId + ".privateKey",
|
||||
name: "privateKey",
|
||||
type: "text",
|
||||
required: true,
|
||||
rows: 8,
|
||||
placeholder: "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----",
|
||||
value: () => data.privateKey || "",
|
||||
oninput: (e) => data.privateKey = e.target.value,
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{ className: "field-help" },
|
||||
"The key is not stored on the server and it is used only for generating the signed JWT.",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
t.footer(
|
||||
{ className: "modal-footer" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "btn transparent m-r-auto",
|
||||
onclick: () => app.modals.close(modal),
|
||||
},
|
||||
t.span({ className: "txt" }, "Close"),
|
||||
),
|
||||
t.button(
|
||||
{
|
||||
"html-form": uniqueId + "_form",
|
||||
type: "submit",
|
||||
className: "btn expanded",
|
||||
},
|
||||
t.i({ className: "ri-key-line" }),
|
||||
t.span({ className: "txt" }, "Generate secret"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return modal;
|
||||
}
|
||||
73
ui/src/collections/oauth2/larkOptions.js
Normal file
73
ui/src/collections/oauth2/larkOptions.js
Normal file
@@ -0,0 +1,73 @@
|
||||
window.app = window.app || {};
|
||||
window.app.oauth2 = window.app.oauth2 || {};
|
||||
|
||||
// note: data is the providerSettingsModal form store
|
||||
window.app.oauth2.lark = function(providerInfo, namePrefix, data) {
|
||||
const uniqueId = "lark_" + app.utils.randomString();
|
||||
|
||||
const domainOptions = [
|
||||
{ label: "Feishu (China)", value: "feishu.cn" },
|
||||
{ label: "Lark (International)", value: "larksuite.com" },
|
||||
];
|
||||
|
||||
const local = store({
|
||||
domain: data.config.authURL?.includes(domainOptions[1].value)
|
||||
? domainOptions[1].value
|
||||
: domainOptions[0].value,
|
||||
});
|
||||
|
||||
const watchers = [
|
||||
watch(() => local.domain, (domain) => {
|
||||
if (domain) {
|
||||
data.config.authURL = `https://accounts.${domain}/open-apis/authen/v1/authorize`;
|
||||
data.config.tokenURL = `https://open.${domain}/open-apis/authen/v2/oauth/token`;
|
||||
data.config.userInfoURL = `https://open.${domain}/open-apis/authen/v1/user_info`;
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
return t.div(
|
||||
{
|
||||
pbEvent: "oauth2LarkOptions",
|
||||
className: "oauth2-lark-options",
|
||||
onunmount: () => {
|
||||
watchers.forEach((w) => w?.unwatch());
|
||||
},
|
||||
},
|
||||
t.div(
|
||||
{ className: "grid" },
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".site" }, "Site"),
|
||||
app.components.select({
|
||||
options: domainOptions,
|
||||
required: true,
|
||||
value: () => local.domain || "",
|
||||
onchange: (selectedOpts) => {
|
||||
local.domain = selectedOpts?.[0]?.value;
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "alert info" },
|
||||
"Note that the Lark user's ",
|
||||
t.strong(null, "Union ID"),
|
||||
" will be used for the association with the PocketBase user (see ",
|
||||
t.a({
|
||||
href:
|
||||
"https://open.feishu.cn/document/platform-overveiw/basic-concepts/user-identity-introduction/introduction#3f2d4b63",
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
textContent: "Different Types of Lark User IDs",
|
||||
}),
|
||||
").",
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
53
ui/src/collections/oauth2/microsoftOptions.js
Normal file
53
ui/src/collections/oauth2/microsoftOptions.js
Normal file
@@ -0,0 +1,53 @@
|
||||
window.app = window.app || {};
|
||||
window.app.oauth2 = window.app.oauth2 || {};
|
||||
|
||||
// note: data is the providerSettingsModal form store
|
||||
window.app.oauth2.microsoft = function(providerInfo, namePrefix, data) {
|
||||
const uniqueId = "microsoft_" + app.utils.randomString();
|
||||
|
||||
return t.div(
|
||||
{ pbEvent: "oauth2MicrosoftOptions", className: "oauth2-microsoft-options" },
|
||||
t.p({ className: "txt-bold" }, "Azure AD endpoints"),
|
||||
t.div(
|
||||
{ className: "grid" },
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".authURL" }, "Auth URL"),
|
||||
t.input({
|
||||
id: uniqueId + ".authURL",
|
||||
name: namePrefix + ".authURL",
|
||||
type: "url",
|
||||
required: true,
|
||||
value: () => data.config.authURL || "",
|
||||
oninput: (e) => data.config.authURL = e.target.value,
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{ className: "field-help" },
|
||||
"Ex. https://login.microsoftonline.com/YOUR_DIRECTORY_TENANT_ID/oauth2/v2.0/authorize",
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".tokenURL" }, "Token URL"),
|
||||
t.input({
|
||||
id: uniqueId + ".tokenURL",
|
||||
name: namePrefix + ".tokenURL",
|
||||
type: "url",
|
||||
required: true,
|
||||
value: () => data.config.tokenURL || "",
|
||||
oninput: (e) => data.config.tokenURL = e.target.value,
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{ className: "field-help" },
|
||||
"Ex. https://login.microsoftonline.com/YOUR_DIRECTORY_TENANT_ID/oauth2/v2.0/token",
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
234
ui/src/collections/oauth2/oidcOptions.js
Normal file
234
ui/src/collections/oauth2/oidcOptions.js
Normal file
@@ -0,0 +1,234 @@
|
||||
window.app = window.app || {};
|
||||
window.app.oauth2 = window.app.oauth2 || {};
|
||||
|
||||
// note: data is the providerSettingsModal form store
|
||||
window.app.oauth2.oidc = function(providerInfo, namePrefix, data) {
|
||||
const uniqueId = "oidc_" + app.utils.randomString();
|
||||
|
||||
const userInfoOptions = [
|
||||
{ label: "User info URL", value: true },
|
||||
{ label: "ID Token", value: false },
|
||||
];
|
||||
|
||||
const local = store({
|
||||
useUserInfoUrl: false,
|
||||
});
|
||||
|
||||
const watchers = [];
|
||||
|
||||
return t.div(
|
||||
{
|
||||
pbEvent: "oauth2OIDCOptions",
|
||||
className: "oauth2-oidc-options",
|
||||
// init defaults
|
||||
onmount: (el) => {
|
||||
if (typeof data.config.displayName == "undefined") {
|
||||
data.config.displayName = "OIDC";
|
||||
}
|
||||
|
||||
if (typeof data.config.pkce == "undefined") {
|
||||
data.config.pkce = true;
|
||||
}
|
||||
|
||||
if (data.config.userInfoURL || !data.config.extra) {
|
||||
local.useUserInfoUrl = true;
|
||||
}
|
||||
|
||||
// unset the id_token or info url fields based on the toggle state
|
||||
watchers.push(
|
||||
watch(() => local.useUserInfoUrl, (useURL, oldUseURL) => {
|
||||
if (useURL) {
|
||||
// note: null because {} will just result in JSON unmarshal merge with the existing data
|
||||
data.config.extra = null;
|
||||
} else {
|
||||
data.config.userInfoURL = "";
|
||||
// note: fallback to empty object to distinguish from the null state since all id_token fields are optional
|
||||
data.config.extra = data.config.extra || {};
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
onunmount: () => {
|
||||
watchers.forEach((w) => w?.unwatch());
|
||||
},
|
||||
},
|
||||
t.div(
|
||||
{ className: "grid" },
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".displayName" }, "Display name"),
|
||||
t.input({
|
||||
id: uniqueId + ".displayName",
|
||||
name: namePrefix + ".displayName",
|
||||
type: "text",
|
||||
required: true,
|
||||
value: () => data.config.displayName || "",
|
||||
oninput: (e) => data.config.displayName = e.target.value,
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.p({ className: "txt-bold" }, "Endpoints"),
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".authURL" }, "Auth URL"),
|
||||
t.input({
|
||||
id: uniqueId + ".authURL",
|
||||
name: namePrefix + ".authURL",
|
||||
type: "url",
|
||||
required: true,
|
||||
value: () => data.config.authURL || "",
|
||||
oninput: (e) => data.config.authURL = e.target.value,
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".tokenURL" }, "Token URL"),
|
||||
t.input({
|
||||
id: uniqueId + ".tokenURL",
|
||||
name: namePrefix + ".tokenURL",
|
||||
type: "url",
|
||||
required: true,
|
||||
value: () => data.config.tokenURL || "",
|
||||
oninput: (e) => data.config.tokenURL = e.target.value,
|
||||
}),
|
||||
),
|
||||
),
|
||||
// User info
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".userInfoSelect" }, "Fetch user info from"),
|
||||
app.components.select({
|
||||
id: uniqueId + ".userInfoSelect",
|
||||
required: true,
|
||||
options: userInfoOptions,
|
||||
value: () => local.useUserInfoUrl,
|
||||
onchange: (selectedOpts) => local.useUserInfoUrl = selectedOpts?.[0]?.value,
|
||||
}),
|
||||
),
|
||||
t.div({ className: "oidc-userinfo-options m-t-10" }, () => {
|
||||
if (local.useUserInfoUrl) {
|
||||
return t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".userInfoURL" }, "User info URL"),
|
||||
t.input({
|
||||
id: uniqueId + ".userInfoURL",
|
||||
name: namePrefix + ".userInfoURL",
|
||||
type: "url",
|
||||
required: true,
|
||||
value: () => data.config.userInfoURL || "",
|
||||
oninput: (e) => data.config.userInfoURL = e.target.value,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return t.div(
|
||||
{ className: "grid sm" },
|
||||
t.div(
|
||||
{ className: "col-12 txt-hint txt-sm" },
|
||||
t.em(
|
||||
null,
|
||||
"Both fields are considered optional because the parsed ",
|
||||
t.code(null, "id_token"),
|
||||
" is a direct result of the TLS code->token exchange server response.",
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId + ".extra.jwksURL" },
|
||||
t.span({ className: "txt" }, "JWKS verification URL"),
|
||||
t.i({
|
||||
ariaHidden: true,
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip(
|
||||
"URL to the public token verification keys.",
|
||||
),
|
||||
}),
|
||||
),
|
||||
t.input({
|
||||
id: uniqueId + ".extra.jwksURL",
|
||||
name: namePrefix + ".extra.jwksURL",
|
||||
type: "url",
|
||||
value: () => data.config.extra?.jwksURL || "",
|
||||
oninput: (e) => {
|
||||
data.config.extra = data.config.extra || {};
|
||||
data.config.extra.jwksURL = e.target.value;
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId + ".extra.issuers" },
|
||||
t.span({ className: "txt" }, "Issuers"),
|
||||
t.i({
|
||||
ariaHidden: true,
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip(
|
||||
"Comma separated list of accepted values for the iss token claim validation.",
|
||||
),
|
||||
}),
|
||||
),
|
||||
t.input({
|
||||
id: uniqueId + ".extra.issuers",
|
||||
name: namePrefix + ".extra.issuers",
|
||||
type: "text",
|
||||
value: () => app.utils.joinNonEmpty(data.config.extra?.issuers),
|
||||
oninput: (e) => {
|
||||
const newValue = app.utils.splitNonEmpty(e.target.value, ",");
|
||||
const newStr = app.utils.joinNonEmpty(newValue);
|
||||
const oldStr = app.utils.joinNonEmpty(data.config.extra?.issuers);
|
||||
|
||||
// has an actual change
|
||||
if (oldStr != newStr) {
|
||||
data.config.extra = data.config.extra || {};
|
||||
data.config.extra.issuers = newValue;
|
||||
}
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.input({
|
||||
id: uniqueId + ".pkce",
|
||||
name: namePrefix + ".pkce",
|
||||
type: "checkbox",
|
||||
checked: () => data.config.pkce || false,
|
||||
onchange: (e) => data.config.pkce = e.target.checked,
|
||||
}),
|
||||
t.label(
|
||||
{ htmlFor: uniqueId + ".pkce" },
|
||||
t.span({ className: "txt", textContent: "Support PKCE" }),
|
||||
t.i({
|
||||
className: "ri-information-line link-hint",
|
||||
ariaHidden: true,
|
||||
ariaDescription: app.attrs.tooltip(
|
||||
"Usually it should be safe to be always enabled as most providers will just ignore the extra query parameters if they don't support PKCE.",
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
115
ui/src/collections/oauth2/selfhostOptions.js
Normal file
115
ui/src/collections/oauth2/selfhostOptions.js
Normal file
@@ -0,0 +1,115 @@
|
||||
window.app = window.app || {};
|
||||
window.app.components = window.app.components || {};
|
||||
|
||||
// note: data is the providerSettingsModal form store
|
||||
window.app.components.oauth2EndpointFields = function(providerInfo, namePrefix, data, settingsArg = {}) {
|
||||
const uniqueId = "endpoints_" + app.utils.randomString();
|
||||
|
||||
const settings = store({
|
||||
required: true,
|
||||
title: "Provider endpoints",
|
||||
});
|
||||
|
||||
const watchers = app.utils.extendStore(settings, settingsArg);
|
||||
|
||||
return t.div(
|
||||
{
|
||||
pbEvent: "oauth2Endpoints",
|
||||
className: "oauth2-endpoints",
|
||||
onunmount: () => {
|
||||
watchers.forEach((w) => w?.unwatch());
|
||||
},
|
||||
},
|
||||
t.p(
|
||||
{ className: "txt-bold" },
|
||||
(el) => {
|
||||
if (typeof settings.title == "function") {
|
||||
settings.title(el);
|
||||
}
|
||||
return settings.title;
|
||||
},
|
||||
),
|
||||
t.div(
|
||||
{ className: "grid" },
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".authURL" }, "Auth URL"),
|
||||
t.input({
|
||||
id: uniqueId + ".authURL",
|
||||
name: namePrefix + ".authURL",
|
||||
type: "url",
|
||||
required: () => !!settings.required,
|
||||
value: () => data.config.authURL || "",
|
||||
oninput: (e) => data.config.authURL = e.target.value,
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".tokenURL" }, "Token URL"),
|
||||
t.input({
|
||||
id: uniqueId + ".tokenURL",
|
||||
name: namePrefix + ".tokenURL",
|
||||
type: "url",
|
||||
required: () => !!settings.required,
|
||||
value: () => data.config.tokenURL || "",
|
||||
oninput: (e) => data.config.tokenURL = e.target.value,
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".userInfoURL" }, "User info URL"),
|
||||
t.input({
|
||||
id: uniqueId + ".userInfoURL",
|
||||
name: namePrefix + ".userInfoURL",
|
||||
type: "url",
|
||||
required: () => !!settings.required,
|
||||
value: () => data.config.userInfoURL || "",
|
||||
oninput: (e) => data.config.userInfoURL = e.target.value,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
window.app.oauth2 = window.app.oauth2 || {};
|
||||
|
||||
window.app.oauth2.gitlab = function(providerInfo, namePrefix, data) {
|
||||
return app.components.oauth2EndpointFields(
|
||||
providerInfo,
|
||||
namePrefix,
|
||||
data,
|
||||
{
|
||||
required: false,
|
||||
title: "Self-hosted endpoints (optional)",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
window.app.oauth2.gitea = function(providerInfo, namePrefix, data) {
|
||||
return app.components.oauth2EndpointFields(
|
||||
providerInfo,
|
||||
namePrefix,
|
||||
data,
|
||||
{
|
||||
required: false,
|
||||
title: "Self-hosted endpoints (optional)",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
window.app.oauth2.mailcow = function(providerInfo, namePrefix, data) {
|
||||
return app.components.oauth2EndpointFields(
|
||||
providerInfo,
|
||||
namePrefix,
|
||||
data,
|
||||
);
|
||||
};
|
||||
314
ui/src/collections/oauth2Accordion.js
Normal file
314
ui/src/collections/oauth2Accordion.js
Normal file
@@ -0,0 +1,314 @@
|
||||
const excludedFieldNames = ["id", "email", "emailVisibility", "verified", "tokenKey", "password"];
|
||||
const allowedRegularTypes = ["text", "editor", "url", "email", "json"];
|
||||
|
||||
export function oauth2Accordion(collection) {
|
||||
const uniqueId = "oauth2_" + app.utils.randomString();
|
||||
|
||||
const data = store({
|
||||
get config() {
|
||||
if (!collection.oauth2) {
|
||||
collection.oauth2 = {
|
||||
enabled: false,
|
||||
mappedFields: {},
|
||||
providers: [],
|
||||
};
|
||||
}
|
||||
|
||||
return collection.oauth2;
|
||||
},
|
||||
get regularFieldOptions() {
|
||||
return collection.fields
|
||||
?.filter((f) => {
|
||||
return allowedRegularTypes.includes(f.type) && !excludedFieldNames.includes(f.name);
|
||||
})
|
||||
.map((f) => {
|
||||
return { value: f.name };
|
||||
});
|
||||
},
|
||||
get regularAndFileFieldOptions() {
|
||||
return collection.fields
|
||||
?.filter((f) => {
|
||||
return (
|
||||
(f.type == "file" || allowedRegularTypes.includes(f.type))
|
||||
&& !excludedFieldNames.includes(f.name)
|
||||
);
|
||||
})
|
||||
.map((f) => {
|
||||
return { value: f.name };
|
||||
});
|
||||
},
|
||||
showMapping: false,
|
||||
});
|
||||
|
||||
function clearProviderErrors(index) {
|
||||
app.utils.deleteByPath(app.store.errors, "oauth2.providers." + index);
|
||||
}
|
||||
|
||||
return t.details(
|
||||
{
|
||||
pbEvent: "oauth2Accordion",
|
||||
name: "auth-methods",
|
||||
className: "accordion oauth2-accordion",
|
||||
},
|
||||
t.summary(
|
||||
null,
|
||||
t.i({ className: "ri-profile-line" }),
|
||||
t.span({ className: "txt", textContent: "OAuth2" }),
|
||||
t.span({
|
||||
className: () => `label m-l-auto ${data.config.enabled ? "success" : ""}`,
|
||||
textContent: () => (data.config.enabled ? "Enabled" : "Disabled"),
|
||||
}),
|
||||
() => {
|
||||
if (app.utils.isEmpty(app.store.errors?.oauth2)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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-sm-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.input({
|
||||
type: "checkbox",
|
||||
id: uniqueId + ".enabled",
|
||||
name: "oauth2.enabled",
|
||||
className: "switch",
|
||||
checked: () => data.config.enabled,
|
||||
onchange: (e) => (data.config.enabled = e.target.checked),
|
||||
}),
|
||||
t.label({
|
||||
htmlFor: uniqueId + ".enabled",
|
||||
textContent: "Enable",
|
||||
}),
|
||||
),
|
||||
),
|
||||
() => {
|
||||
return data.config.providers.map((providerConfig, configIndex) => {
|
||||
const providerId = uniqueId + providerConfig.name;
|
||||
const providerInfo = app.store.oauth2Providers?.find((p) => p.name == providerConfig.name) || {};
|
||||
|
||||
return t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{
|
||||
className: () => {
|
||||
let result = "provider-card";
|
||||
|
||||
if (!app.utils.isEmpty(app.store.errors?.oauth2?.providers?.[configIndex])) {
|
||||
result += " error";
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
},
|
||||
t.figure(
|
||||
{ className: "provider-logo" },
|
||||
() => {
|
||||
if (providerInfo.logo) {
|
||||
return t.img({
|
||||
src: "data:image/svg+xml;base64," + btoa(providerInfo.logo),
|
||||
alt: providerConfig.name + " logo",
|
||||
});
|
||||
}
|
||||
|
||||
return t.i({ className: app.utils.fallbackProviderIcon });
|
||||
},
|
||||
),
|
||||
t.div(
|
||||
{ className: "content" },
|
||||
t.span(
|
||||
{ className: "primary-txt" },
|
||||
() => providerConfig.displayName || providerInfo.displayName || providerInfo.name,
|
||||
),
|
||||
t.span({ className: "secondary-txt" }, () => providerConfig.name || providerInfo.name),
|
||||
),
|
||||
t.div(
|
||||
{ className: "actions" },
|
||||
t.button(
|
||||
{
|
||||
"type": "button",
|
||||
"className": "btn secondary transparent sm circle",
|
||||
"html-popovertarget": providerId + "dropdown",
|
||||
},
|
||||
t.i({ className: "ri-more-2-line" }),
|
||||
),
|
||||
t.div(
|
||||
{
|
||||
id: providerId + "dropdown",
|
||||
className: "dropdown sm",
|
||||
popover: "auto",
|
||||
},
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "dropdown-item",
|
||||
onclick: (e) => {
|
||||
e.target.closest(".dropdown").hidePopover();
|
||||
app.modals.openProviderSettings(providerConfig, {
|
||||
namePrefix: "oauth2.providers." + configIndex,
|
||||
onsubmit: (providerInfo, providerConfig) => {
|
||||
data.config.providers[configIndex] = providerConfig;
|
||||
clearProviderErrors(configIndex);
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
t.span({ className: "txt" }, "Settings"),
|
||||
),
|
||||
t.hr(),
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "dropdown-item",
|
||||
onclick: (e) => {
|
||||
e.target.closest(".dropdown").hidePopover();
|
||||
app.modals.confirm(
|
||||
`Do you really want to remove provider "${
|
||||
providerConfig.displayName || providerInfo.displayName
|
||||
|| providerInfo.name
|
||||
}"?`,
|
||||
() => {
|
||||
clearProviderErrors(configIndex);
|
||||
|
||||
data.config.providers.splice(configIndex, 1);
|
||||
|
||||
if (data.config.providers.length == 0) {
|
||||
data.config.enabled = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
t.span({ className: "txt" }, "Remove"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "btn lg block secondary add-provider-btn",
|
||||
onclick: () => {
|
||||
app.modals.openProviderPicker({
|
||||
exclude: data.config.providers.map((p) => p.name),
|
||||
onselect: (providerInfo) => {
|
||||
app.modals.openProviderSettings({ name: providerInfo.name }, {
|
||||
onsubmit: (providerInfo, providerConfig) => {
|
||||
if (data.config.providers.length == 0) {
|
||||
data.config.enabled = true;
|
||||
}
|
||||
|
||||
data.config.providers.push(providerConfig);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
t.i({ className: "ri-add-line" }),
|
||||
t.span({ className: "txt " }, "Add provider"),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-12" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: () => `btn secondary sm ${data.showMapping ? "" : "transparent"}`,
|
||||
onclick: () => (data.showMapping = !data.showMapping),
|
||||
},
|
||||
t.span({ className: "txt" }, "Optional users create fields mapping"),
|
||||
t.i({
|
||||
className: () => (data.showMapping ? "ri-arrow-drop-up-line" : "ri-arrow-drop-down-line"),
|
||||
}),
|
||||
),
|
||||
app.components.slide(
|
||||
() => data.showMapping,
|
||||
t.div(
|
||||
{ className: "grid sm m-t-sm" },
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".mappedFields.name" }, "OAuth2 full name"),
|
||||
app.components.select({
|
||||
id: uniqueId + ".mappedFields.name",
|
||||
name: "oauth2.mappedFields.name",
|
||||
placeholder: "Select field",
|
||||
options: () => data.regularFieldOptions,
|
||||
value: () => collection.oauth2.mappedFields.name,
|
||||
onchange: (selectedOpts) => {
|
||||
collection.oauth2.mappedFields.name = selectedOpts?.[0]?.value || "";
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".mappedFields.avatarURL" }, "OAuth2 avatar"),
|
||||
app.components.select({
|
||||
id: uniqueId + ".mappedFields.avatarURL",
|
||||
name: "oauth2.mappedFields.avatarURL",
|
||||
placeholder: "Select field",
|
||||
options: () => data.regularAndFileFieldOptions,
|
||||
value: () => collection.oauth2.mappedFields.avatarURL,
|
||||
onchange: (selectedOpts) => {
|
||||
collection.oauth2.mappedFields.avatarURL = selectedOpts?.[0]?.value || "";
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".mappedFields.id" }, "OAuth2 id"),
|
||||
app.components.select({
|
||||
id: uniqueId + ".mappedFields.id",
|
||||
name: "oauth2.mappedFields.id",
|
||||
placeholder: "Select field",
|
||||
options: () => data.regularFieldOptions,
|
||||
value: () => collection.oauth2.mappedFields.id,
|
||||
onchange: (selectedOpts) => {
|
||||
collection.oauth2.mappedFields.id = selectedOpts?.[0]?.value || "";
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".mappedFields.username" }, "OAuth2 username"),
|
||||
app.components.select({
|
||||
id: uniqueId + ".mappedFields.username",
|
||||
name: "oauth2.mappedFields.username",
|
||||
placeholder: "Select field",
|
||||
options: () => data.regularFieldOptions,
|
||||
value: () => collection.oauth2.mappedFields.username,
|
||||
onchange: (selectedOpts) => {
|
||||
collection.oauth2.mappedFields.username = selectedOpts?.[0]?.value || "";
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
105
ui/src/collections/otpAccordion.js
Normal file
105
ui/src/collections/otpAccordion.js
Normal file
@@ -0,0 +1,105 @@
|
||||
export function otpAccordion(collection) {
|
||||
const uniqueId = "otp_" + app.utils.randomString();
|
||||
|
||||
const data = store({
|
||||
get config() {
|
||||
if (!collection.otp) {
|
||||
collection.otp = {
|
||||
enabled: false,
|
||||
duration: 300,
|
||||
length: 8,
|
||||
};
|
||||
}
|
||||
|
||||
return collection.otp;
|
||||
},
|
||||
});
|
||||
|
||||
return t.details(
|
||||
{
|
||||
pbEvent: "otpAccordion",
|
||||
name: "auth-methods",
|
||||
className: "accordion otp-accordion",
|
||||
},
|
||||
t.summary(
|
||||
null,
|
||||
t.i({ className: "ri-time-line" }),
|
||||
t.span({ className: "txt", textContent: "One-time password (OTP)" }),
|
||||
t.span({
|
||||
className: () => `label m-l-auto ${data.config.enabled ? "success" : ""}`,
|
||||
textContent: () => (data.config.enabled ? "Enabled" : "Disabled"),
|
||||
}),
|
||||
() => {
|
||||
if (!app.store.errors?.otp) {
|
||||
return;
|
||||
}
|
||||
|
||||
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-sm-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.input({
|
||||
type: "checkbox",
|
||||
id: uniqueId + ".enabled",
|
||||
name: "otp.enabled",
|
||||
className: "switch",
|
||||
checked: () => data.config.enabled,
|
||||
onchange: (e) => (data.config.enabled = e.target.checked),
|
||||
}),
|
||||
t.label({
|
||||
htmlFor: uniqueId + ".enabled",
|
||||
textContent: "Enable",
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({
|
||||
htmlFor: uniqueId + ".duration",
|
||||
textContent: "Duration (in seconds)",
|
||||
}),
|
||||
t.input({
|
||||
type: "number",
|
||||
id: uniqueId + ".duration",
|
||||
name: "otp.duration",
|
||||
min: 1,
|
||||
step: 1,
|
||||
required: true,
|
||||
value: () => data.config.duration || "",
|
||||
oninput: (e) => (data.config.duration = parseInt(e.target.value, 10)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({
|
||||
htmlFor: uniqueId + ".length",
|
||||
textContent: "Generated password length",
|
||||
}),
|
||||
t.input({
|
||||
type: "number",
|
||||
id: uniqueId + ".length",
|
||||
name: "otp.length",
|
||||
min: 1,
|
||||
step: 1,
|
||||
required: true,
|
||||
value: () => data.config.length || "",
|
||||
oninput: (e) => (data.config.length = parseInt(e.target.value, 10)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
331
ui/src/collections/pageCollections.js
Normal file
331
ui/src/collections/pageCollections.js
Normal file
@@ -0,0 +1,331 @@
|
||||
import { collectionsSidebar } from "./collectionsSidebar";
|
||||
|
||||
const SORT_QUERY_KEY = "sort";
|
||||
const FILTER_QUERY_KEY = "filter";
|
||||
const COLLECTION_QUERY_KEY = "collection";
|
||||
const RECORD_QUERY_KEY = "record";
|
||||
const LAST_ACTIVE_STORAGE_KEY = "pbLastActiveCollection";
|
||||
|
||||
export function pageCollections(route) {
|
||||
app.store.activeCollection = route.query[COLLECTION_QUERY_KEY]?.[0]
|
||||
|| window.localStorage.getItem(LAST_ACTIVE_STORAGE_KEY);
|
||||
|
||||
const pageData = store({
|
||||
reset: null,
|
||||
activeRecordIdOrModel: route.query[RECORD_QUERY_KEY]?.[0] || "",
|
||||
sort: route.query[SORT_QUERY_KEY]?.[0] || "",
|
||||
filter: route.query[FILTER_QUERY_KEY]?.[0] || "",
|
||||
totalCount: 0,
|
||||
isTotalCountLoading: false,
|
||||
});
|
||||
|
||||
async function loadTotalCount() {
|
||||
if (!app.store.activeCollection?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageData.isTotalCountLoading = true;
|
||||
|
||||
try {
|
||||
const normalizedFilter = app.utils.normalizeSearchFilter(
|
||||
pageData.filter,
|
||||
app.store.activeCollection.fields.filter((f) => !f.hidden).map((f) => f.name),
|
||||
);
|
||||
|
||||
const result = await app.pb.collection(app.store.activeCollection.name).getList(1, 1, {
|
||||
filter: normalizedFilter,
|
||||
fields: "id",
|
||||
});
|
||||
|
||||
pageData.totalCount = result.totalItems;
|
||||
} catch (err) {
|
||||
if (!err.isAbort) {
|
||||
pageData.totalCount = 0;
|
||||
console.warn("failed to load total count:", err);
|
||||
}
|
||||
}
|
||||
|
||||
pageData.isTotalCountLoading = false;
|
||||
}
|
||||
|
||||
function refreshRecordsList() {
|
||||
pageData.reset = Date.now();
|
||||
}
|
||||
|
||||
const watchers = [
|
||||
watch(
|
||||
() => (app.store.activeCollection?.name || "") + (app.store.activeCollection?.updated || ""),
|
||||
(newVal, oldVal) => {
|
||||
// skip unnecessery initial params replacement
|
||||
if (!oldVal) {
|
||||
return;
|
||||
}
|
||||
|
||||
// reset filter and sort params on collection change
|
||||
if (oldVal != newVal) {
|
||||
pageData.filter = "";
|
||||
pageData.sort = "";
|
||||
}
|
||||
|
||||
app.store.title = app.store.activeCollection?.name || "Collections";
|
||||
|
||||
app.utils.replaceHashQueryParams({
|
||||
[COLLECTION_QUERY_KEY]: app.store.activeCollection?.name,
|
||||
[FILTER_QUERY_KEY]: pageData.filter || null,
|
||||
[SORT_QUERY_KEY]: pageData.sort || null,
|
||||
}, newVal != oldVal ? true : null);
|
||||
|
||||
if (app.store.activeCollection?.id) {
|
||||
window.localStorage.setItem(LAST_ACTIVE_STORAGE_KEY, app.store.activeCollection.id);
|
||||
} else {
|
||||
window.localStorage.removeItem(LAST_ACTIVE_STORAGE_KEY);
|
||||
}
|
||||
},
|
||||
),
|
||||
watch(
|
||||
() => [pageData.filter, pageData.sort],
|
||||
(newVal, oldVal) => {
|
||||
if (!oldVal) {
|
||||
return;
|
||||
}
|
||||
|
||||
app.utils.replaceHashQueryParams({
|
||||
[FILTER_QUERY_KEY]: pageData.filter || null,
|
||||
[SORT_QUERY_KEY]: pageData.sort || null,
|
||||
});
|
||||
},
|
||||
),
|
||||
watch(
|
||||
() => (pageData.activeRecordIdOrModel || "") + (app.store.activeCollection?.id || ""),
|
||||
(newVal, oldVal) => {
|
||||
if (!pageData.activeRecordIdOrModel) {
|
||||
app.utils.replaceHashQueryParams({
|
||||
[RECORD_QUERY_KEY]: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// no change or the collection model is still loading
|
||||
if (newVal == oldVal || !app.store.activeCollection?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recordData = typeof pageData.activeRecordIdOrModel == "string"
|
||||
? {
|
||||
id: pageData.activeRecordIdOrModel,
|
||||
collectionId: app.store.activeCollection?.id,
|
||||
collectionName: app.store.activeCollection?.name,
|
||||
}
|
||||
: pageData.activeRecordIdOrModel;
|
||||
|
||||
app.utils.replaceHashQueryParams({
|
||||
[RECORD_QUERY_KEY]: recordData.id || null,
|
||||
});
|
||||
|
||||
// force close any previous modal
|
||||
app.modals.close(null, true);
|
||||
|
||||
if (app.store.activeCollection?.type == "view") {
|
||||
app.modals.openRecordPreview(recordData, {
|
||||
onafterclose: () => {
|
||||
pageData.activeRecordIdOrModel = "";
|
||||
},
|
||||
});
|
||||
} else {
|
||||
app.modals.openRecordUpsert(app.store.activeCollection, recordData, {
|
||||
onafterclose: () => {
|
||||
pageData.activeRecordIdOrModel = "";
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
watch(
|
||||
() => [app.store.activeCollection?.id, pageData.filter, pageData.reset],
|
||||
() => loadTotalCount(),
|
||||
),
|
||||
];
|
||||
|
||||
const documentEvents = {
|
||||
"record:save": (e) => {
|
||||
if (e.detail.collectionId != app.store.activeCollection?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageData.totalCount++;
|
||||
},
|
||||
"record:delete": (e) => {
|
||||
if (
|
||||
// check both because for delete we don't know which one was assigned to
|
||||
e.detail.collectionId != app.store.activeCollection?.id
|
||||
&& e.detail.collectionName != app.store.activeCollection?.name
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageData.totalCount--;
|
||||
},
|
||||
};
|
||||
|
||||
return t.div(
|
||||
{
|
||||
pbEvent: "pageCollections",
|
||||
className: "page",
|
||||
onmount: () => {
|
||||
// refresh if necesser the cached collections in the background
|
||||
if (!app.store.isLoadingCollections) {
|
||||
app.store.silentlyReloadCollections();
|
||||
}
|
||||
|
||||
for (let event in documentEvents) {
|
||||
document.addEventListener(event, documentEvents[event]);
|
||||
}
|
||||
},
|
||||
onunmount: () => {
|
||||
watchers.forEach((w) => w?.unwatch());
|
||||
|
||||
for (let event in documentEvents) {
|
||||
document.removeEventListener(event, documentEvents[event]);
|
||||
}
|
||||
},
|
||||
},
|
||||
() => collectionsSidebar(),
|
||||
t.div(
|
||||
{ className: "page-content full-height" },
|
||||
t.header(
|
||||
{ className: "page-header compact flex-nowrap" },
|
||||
t.nav(
|
||||
{ className: "breadcrumbs" },
|
||||
t.div(null, "Collections"),
|
||||
() => {
|
||||
if (app.store.activeCollection?.name) {
|
||||
return t.div({
|
||||
title: app.store.activeCollection.name,
|
||||
textContent: app.store.activeCollection.name,
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
t.div(
|
||||
{
|
||||
hidden: () => !app.store.activeCollection?.id,
|
||||
pbEvent: "pageHeaderSecondaryBtns",
|
||||
className: "page-header-secondary-btns",
|
||||
},
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "btn circle transparent secondary tooltip-bottom btn-collection-settings",
|
||||
ariaDescription: app.attrs.tooltip("Collection settings"),
|
||||
onclick: () => {
|
||||
app.modals.openCollectionUpsert(app.store.activeCollection, {
|
||||
ontruncate: () => refreshRecordsList(),
|
||||
onsave: (collection, isNew) => {
|
||||
if (isNew) {
|
||||
// e.g. in case of a duplicate or modal state reset
|
||||
app.store.activeCollection = collection.id;
|
||||
} else {
|
||||
refreshRecordsList();
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
t.i({ className: "ri-settings-3-line" }),
|
||||
),
|
||||
app.components.refreshButton({
|
||||
onclick: () => refreshRecordsList(),
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{
|
||||
hidden: () => !app.store.activeCollection?.id,
|
||||
pbEvent: "pageHeaderPrimaryBtns",
|
||||
className: "page-header-primary-btns",
|
||||
},
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "btn outline api-preview-btn",
|
||||
onclick: () => app.modals.openApiPreview(app.store.activeCollection),
|
||||
},
|
||||
t.i({ className: "ri-code-s-slash-line" }),
|
||||
t.span({ className: "txt", textContent: "API preview" }),
|
||||
),
|
||||
() => {
|
||||
if (app.store.activeCollection?.type == "view") {
|
||||
return;
|
||||
}
|
||||
|
||||
return t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "btn new-record-btn",
|
||||
onclick: () => app.modals.openRecordUpsert(app.store.activeCollection),
|
||||
},
|
||||
t.i({ className: "ri-add-line" }),
|
||||
t.span({ className: "txt", textContent: "New Record" }),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// page loader
|
||||
t.div(
|
||||
{
|
||||
hidden: () => !app.store.isLoadingCollections || app.store.activeCollection?.id,
|
||||
className: "block txt-center p-base",
|
||||
},
|
||||
t.span({ className: "loader lg" }),
|
||||
),
|
||||
// no selected collection
|
||||
t.div(
|
||||
{
|
||||
hidden: () => app.store.isLoadingCollections || app.store.activeCollection?.id,
|
||||
className: "block txt-center p-base",
|
||||
},
|
||||
t.h6(
|
||||
{ className: "txt" },
|
||||
() => {
|
||||
if (app.store.collections?.length) {
|
||||
return "Select collection from the sidebar.";
|
||||
}
|
||||
return "No collections found.";
|
||||
},
|
||||
),
|
||||
),
|
||||
// records list
|
||||
app.components.recordsSearchbar({
|
||||
hidden: () => !app.store.activeCollection?.id,
|
||||
collection: () => app.store.activeCollection,
|
||||
value: () => pageData.filter,
|
||||
onsubmit: (newFilter) => (pageData.filter = newFilter),
|
||||
}),
|
||||
app.components.recordsList({
|
||||
className: "m-t-sm",
|
||||
reset: () => pageData.reset,
|
||||
hidden: () => !app.store.activeCollection?.id,
|
||||
collection: () => app.store.activeCollection,
|
||||
filter: () => pageData.filter,
|
||||
sort: () => pageData.sort,
|
||||
onselect: (record) => {
|
||||
pageData.activeRecordIdOrModel = record;
|
||||
},
|
||||
onchange: (newFilter, newSort) => {
|
||||
pageData.filter = newFilter;
|
||||
pageData.sort = newSort;
|
||||
},
|
||||
}),
|
||||
t.footer(
|
||||
{ className: "page-footer" },
|
||||
t.span(
|
||||
{
|
||||
className: () => `total-count ${pageData.isTotalCountLoading ? "faded" : ""}`,
|
||||
},
|
||||
"Total: ",
|
||||
() => pageData.totalCount,
|
||||
),
|
||||
app.components.credits(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
112
ui/src/collections/passwordAuthAccordion.js
Normal file
112
ui/src/collections/passwordAuthAccordion.js
Normal file
@@ -0,0 +1,112 @@
|
||||
export function passwordAuthAccordion(collection) {
|
||||
const uniqueId = "passwordAuth_" + app.utils.randomString();
|
||||
|
||||
const data = store({
|
||||
get config() {
|
||||
if (!collection.passwordAuth) {
|
||||
collection.passwordAuth = {
|
||||
enabled: true,
|
||||
identityFields: ["email"],
|
||||
};
|
||||
}
|
||||
|
||||
return collection.passwordAuth;
|
||||
},
|
||||
get identityFieldOptions() {
|
||||
// email is always available in auth collections
|
||||
const options = [{ value: "email" }];
|
||||
|
||||
const fields = collection?.fields || [];
|
||||
const indexes = collection?.indexes || [];
|
||||
|
||||
for (let index of indexes) {
|
||||
const parsed = app.utils.parseIndex(index);
|
||||
if (!parsed.unique || parsed.columns.length != 1 || parsed.columns[0].name == "email") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const field = fields.find((f) => {
|
||||
return !f.hidden && f.name.toLowerCase() == parsed.columns[0].name.toLowerCase();
|
||||
});
|
||||
if (field) {
|
||||
options.push({ value: field.name });
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
},
|
||||
});
|
||||
|
||||
return t.details(
|
||||
{
|
||||
pbEvent: "passwordAuthAccordion",
|
||||
name: "auth-methods",
|
||||
className: "accordion password-auth-accordion",
|
||||
},
|
||||
t.summary(
|
||||
null,
|
||||
t.i({ className: "ri-lock-password-line" }),
|
||||
t.span({ className: "txt", textContent: "Identity/Password" }),
|
||||
t.span({
|
||||
className: () => `label m-l-auto ${data.config.enabled ? "success" : ""}`,
|
||||
textContent: () => (data.config.enabled ? "Enabled" : "Disabled"),
|
||||
}),
|
||||
() => {
|
||||
if (!app.store.errors?.passwordAuth) {
|
||||
return;
|
||||
}
|
||||
|
||||
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-sm-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.input({
|
||||
type: "checkbox",
|
||||
id: uniqueId + ".enabled",
|
||||
name: "passwordAuth.enabled",
|
||||
className: "switch",
|
||||
checked: () => data.config.enabled,
|
||||
onchange: (e) => (data.config.enabled = e.target.checked),
|
||||
}),
|
||||
t.label({
|
||||
htmlFor: uniqueId + ".enabled",
|
||||
textContent: "Enable",
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({
|
||||
htmlFor: uniqueId + ".identityFields",
|
||||
textContent: "Identity fields",
|
||||
}),
|
||||
app.components.select({
|
||||
id: uniqueId + ".identityFields",
|
||||
name: "passwordAuth.identityFields",
|
||||
max: 99,
|
||||
required: true,
|
||||
options: () => data.identityFieldOptions,
|
||||
value: () => data.config.identityFields,
|
||||
onchange: (selectedOpts) => {
|
||||
data.config.identityFields = selectedOpts.map((opt) => opt.value);
|
||||
},
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{ className: "field-help" },
|
||||
"Only non-hidden fields with UNIQUE index constraint can be selected.",
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
175
ui/src/collections/providerPickerModal.js
Normal file
175
ui/src/collections/providerPickerModal.js
Normal file
@@ -0,0 +1,175 @@
|
||||
window.app = window.app || {};
|
||||
window.app.modals = window.app.modals || {};
|
||||
|
||||
window.app.modals.openProviderPicker = function(settings = {
|
||||
exclude: [],
|
||||
// ---
|
||||
onbeforeopen: null,
|
||||
onafteropen: null,
|
||||
onbeforeclose: null,
|
||||
onafterclose: null,
|
||||
onselect: null, // (providerInfo) => {},
|
||||
}) {
|
||||
const modal = providerPickerModal(settings);
|
||||
if (!modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.appendChild(modal);
|
||||
app.modals.open(modal);
|
||||
};
|
||||
|
||||
function providerPickerModal(settings = {}) {
|
||||
let modal;
|
||||
|
||||
const data = store({
|
||||
searchTerm: "",
|
||||
get filteredProviders() {
|
||||
const search = data.searchTerm.trim().toLowerCase().replaceAll(" ", "");
|
||||
|
||||
return app.store.oauth2Providers.filter((p) => {
|
||||
return (
|
||||
!settings.exclude?.includes(p.name)
|
||||
&& (p.name + p.displayName).toLowerCase().replaceAll(" ", "").includes(search)
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function clearSearch() {
|
||||
data.searchTerm = "";
|
||||
}
|
||||
|
||||
modal = t.div(
|
||||
{
|
||||
pbEvent: "providerPickerModal",
|
||||
className: "modal provider-picker-modal",
|
||||
onbeforeopen: (el) => {
|
||||
return settings.onbeforeopen?.(el);
|
||||
},
|
||||
onafteropen: (el) => {
|
||||
settings.onafteropen?.(el);
|
||||
},
|
||||
onbeforeclose: (el) => {
|
||||
return settings.onbeforeclose?.(el);
|
||||
},
|
||||
onafterclose: (el) => {
|
||||
settings.onafterclose?.(el);
|
||||
el?.remove();
|
||||
},
|
||||
},
|
||||
t.header(
|
||||
{ className: "modal-header" },
|
||||
t.h6({ className: "modal-title" }, t.span({ className: "txt" }, "Select OAuth2 provider")),
|
||||
),
|
||||
t.div(
|
||||
{ className: "modal-content" },
|
||||
t.div(
|
||||
{ className: "grid sm" },
|
||||
// search
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "fields searchbar" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.input({
|
||||
placeholder: "Search...",
|
||||
className: "p-l-20",
|
||||
value: () => data.searchTerm,
|
||||
oninput: (e) => data.searchTerm = e.target.value,
|
||||
}),
|
||||
),
|
||||
() => {
|
||||
if (!data.searchTerm) {
|
||||
return;
|
||||
}
|
||||
|
||||
return t.div(
|
||||
{ rid: "search-ctrls", className: "field addon p-r-5" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "btn sm pill secondary transparent",
|
||||
onclick: () => clearSearch(),
|
||||
},
|
||||
"Clear",
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// no providers
|
||||
() => {
|
||||
if (app.store.isLoadingOAuth2Providers || data.filteredProviders.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return t.div(
|
||||
{ rid: "notfound", className: "block txt-center txt-hint" },
|
||||
t.p(null, "No providers found."),
|
||||
t.button({
|
||||
type: "button",
|
||||
className: "btn sm secondary",
|
||||
textContent: "Clear search",
|
||||
onclick: () => clearSearch(),
|
||||
}),
|
||||
);
|
||||
},
|
||||
// list
|
||||
() => {
|
||||
if (app.store.isLoadingOAuth2Providers) {
|
||||
return t.div({ className: "col-12 txt-center" }, t.span({ className: "loader active" }));
|
||||
}
|
||||
|
||||
return data.filteredProviders.map((provider) => {
|
||||
return t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "provider-card handle",
|
||||
onclick: () => {
|
||||
app.modals.close(modal);
|
||||
settings.onselect?.(provider);
|
||||
},
|
||||
},
|
||||
t.figure(
|
||||
{ className: "provider-logo" },
|
||||
() => {
|
||||
if (provider.logo) {
|
||||
return t.img({
|
||||
src: "data:image/svg+xml;base64," + btoa(provider.logo),
|
||||
alt: provider.name + " logo",
|
||||
});
|
||||
}
|
||||
|
||||
return t.i({ className: app.utils.fallbackProviderIcon });
|
||||
},
|
||||
),
|
||||
t.div(
|
||||
{ className: "content" },
|
||||
t.span({ className: "primadry-txt" }, provider.displayName || provider.name),
|
||||
t.span({ className: "secondary-txt" }, provider.name),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
t.footer(
|
||||
{ className: "modal-footer gap-base" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "btn transparent m-r-auto",
|
||||
onclick: () => app.modals.close(modal),
|
||||
},
|
||||
t.span({ className: "txt" }, "Close"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return modal;
|
||||
}
|
||||
200
ui/src/collections/providerSettingsModal.js
Normal file
200
ui/src/collections/providerSettingsModal.js
Normal file
@@ -0,0 +1,200 @@
|
||||
window.app = window.app || {};
|
||||
window.app.modals = window.app.modals || {};
|
||||
|
||||
window.app.modals.openProviderSettings = function(
|
||||
config = {},
|
||||
settings = {
|
||||
namePrefix: "", // e.g. 'oauth2.providers.0'
|
||||
// ---
|
||||
onbeforeopen: null,
|
||||
onafteropen: null,
|
||||
onbeforeclose: null,
|
||||
onafterclose: null,
|
||||
onsubmit: null, // (providerInfo, newConfig) => {},
|
||||
},
|
||||
) {
|
||||
const modal = providerSettingsModal(config, settings);
|
||||
if (!modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.appendChild(modal);
|
||||
app.modals.open(modal);
|
||||
};
|
||||
|
||||
function providerSettingsModal(providerConfig, settings) {
|
||||
let modal;
|
||||
|
||||
const uniqueId = "provider_" + app.utils.randomString();
|
||||
|
||||
providerConfig = providerConfig || {};
|
||||
|
||||
const isNew = !providerConfig.clientId;
|
||||
|
||||
const initialHash = JSON.stringify(providerConfig);
|
||||
|
||||
const providerInfo = app.store.oauth2Providers?.find((p) => p.name == providerConfig.name);
|
||||
if (!providerInfo) {
|
||||
console.warn("missing provider for config", providerConfig);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = store({
|
||||
config: JSON.parse(initialHash),
|
||||
get hasChanges() {
|
||||
return initialHash != JSON.stringify(data.config);
|
||||
},
|
||||
onsubmit: (providerInfo, newConfig) => {},
|
||||
});
|
||||
|
||||
function submit() {
|
||||
if (!data.hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
settings.onsubmit?.(providerInfo, JSON.parse(JSON.stringify(data.config)));
|
||||
|
||||
app.modals.close(modal);
|
||||
}
|
||||
|
||||
modal = t.div(
|
||||
{
|
||||
pbEvent: "providerSettingsModal",
|
||||
className: "modal provider-settings-modal",
|
||||
onbeforeopen: (el) => {
|
||||
return settings.onbeforeopen?.(el);
|
||||
},
|
||||
onafteropen: (el) => {
|
||||
settings.onafteropen?.(el);
|
||||
|
||||
// retriger errors (if any)
|
||||
setTimeout(() => {
|
||||
if (app.store.errors?.oauth2) {
|
||||
app.store.errors.oauth2 = JSON.parse(JSON.stringify(app.store.errors.oauth2));
|
||||
}
|
||||
}, 0);
|
||||
},
|
||||
onbeforeclose: (el) => {
|
||||
return settings.onbeforeclose?.(el);
|
||||
},
|
||||
onafterclose: (el) => {
|
||||
settings.onafterclose?.(el);
|
||||
el?.remove();
|
||||
},
|
||||
},
|
||||
t.header(
|
||||
{ className: "modal-header" },
|
||||
t.figure(
|
||||
{ className: "provider-logo" },
|
||||
() => {
|
||||
if (providerInfo.logo) {
|
||||
return t.img({
|
||||
src: "data:image/svg+xml;base64," + btoa(providerInfo.logo),
|
||||
alt: providerInfo.name + " logo",
|
||||
});
|
||||
}
|
||||
|
||||
return t.i({ className: app.utils.fallbackProviderIcon });
|
||||
},
|
||||
),
|
||||
t.h6(
|
||||
{ className: "modal-title" },
|
||||
providerConfig.displayName || providerInfo.displayName || providerInfo.name,
|
||||
t.small({ className: "txt-hint" }, " (", providerConfig.name, ")"),
|
||||
),
|
||||
),
|
||||
t.form(
|
||||
{
|
||||
pbEvent: "providerSettingsForm",
|
||||
id: uniqueId + "form",
|
||||
className: "modal-content",
|
||||
onsubmit: (e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
},
|
||||
},
|
||||
t.div(
|
||||
{ className: "grid" },
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({
|
||||
htmlFor: uniqueId + ".clientId",
|
||||
textContent: "Client ID",
|
||||
}),
|
||||
t.input({
|
||||
type: "text",
|
||||
required: true,
|
||||
id: uniqueId + ".clientId",
|
||||
autocomplete: "off",
|
||||
name: () => settings.namePrefix + ".clientId",
|
||||
value: () => data.config.clientId || "",
|
||||
oninput: (e) => (data.config.clientId = e.target.value.trim()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({
|
||||
htmlFor: uniqueId + ".clientSecret",
|
||||
textContent: "Client secret",
|
||||
}),
|
||||
t.input({
|
||||
type: "password",
|
||||
id: uniqueId + ".clientSecret",
|
||||
autocomplete: "new-password",
|
||||
required: () => isNew || typeof data.config.clientSecret != "undefined",
|
||||
name: () => settings.namePrefix + ".clientSecret",
|
||||
value: () => data.config.clientSecret || "",
|
||||
oninput: (e) => (data.config.clientSecret = e.target.value.trim()),
|
||||
onkeyup: (e) => {
|
||||
if (
|
||||
e.key == "Backspace"
|
||||
&& typeof data.config.clientSecret === "undefined"
|
||||
) {
|
||||
data.config.clientSecret = "";
|
||||
}
|
||||
},
|
||||
placeholder:
|
||||
() => (isNew || typeof data.config.clientSecret !== "undefined" ? "" : "* * * * * *"),
|
||||
}),
|
||||
),
|
||||
),
|
||||
// extra fields
|
||||
() => {
|
||||
if (typeof app.oauth2?.[providerInfo.name] == "function") {
|
||||
return t.div(
|
||||
{ className: "col-12" },
|
||||
app.oauth2[providerInfo.name](providerInfo, settings.namePrefix, data),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
t.footer(
|
||||
{ className: "modal-footer" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "btn transparent m-r-auto",
|
||||
onclick: () => app.modals.close(modal),
|
||||
},
|
||||
t.span({ className: "txt" }, "Close"),
|
||||
),
|
||||
t.button(
|
||||
{
|
||||
"html-form": uniqueId + "form",
|
||||
type: "submit",
|
||||
className: "btn",
|
||||
disabled: () => !data.hasChanges,
|
||||
},
|
||||
t.span({ className: "txt" }, "Set provider config"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return modal;
|
||||
}
|
||||
78
ui/src/collections/tokenOptionsAccordion.js
Normal file
78
ui/src/collections/tokenOptionsAccordion.js
Normal file
@@ -0,0 +1,78 @@
|
||||
export function tokenOptionsAccordion(collection) {
|
||||
const uniqueId = "token_" + app.utils.randomString();
|
||||
|
||||
const data = store({
|
||||
get tokensList() {
|
||||
if (collection?.name === "_superusers") {
|
||||
return [
|
||||
{ key: "authToken", label: "Auth" },
|
||||
{ key: "passwordResetToken", label: "Password reset" },
|
||||
{ key: "fileToken", label: "Protected file" },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ key: "authToken", label: "Auth" },
|
||||
{ key: "verificationToken", label: "Email verification" },
|
||||
{ key: "passwordResetToken", label: "Password reset" },
|
||||
{ key: "emailChangeToken", label: "Email change" },
|
||||
{ key: "fileToken", label: "Protected file" },
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
return t.details(
|
||||
{
|
||||
pbEvent: "tokenOptionsAccordion",
|
||||
name: "other",
|
||||
className: "accordion token-options-accordion",
|
||||
},
|
||||
t.summary(
|
||||
null,
|
||||
t.i({ className: "ri-key-2-line" }),
|
||||
t.span({ className: "txt", textContent: "Token options (invalidate, duration)" }),
|
||||
),
|
||||
t.div({ className: "grid sm" }, () => {
|
||||
return data.tokensList.map((token) => {
|
||||
const fieldId = uniqueId + token.key;
|
||||
|
||||
return t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field token-field" },
|
||||
t.label({
|
||||
htmlFor: fieldId,
|
||||
textContent: () => token.label + " duration (in seconds)",
|
||||
}),
|
||||
t.input({
|
||||
id: fieldId,
|
||||
type: "number",
|
||||
min: 1,
|
||||
step: 1,
|
||||
required: true,
|
||||
name: () => token.key + ".duration",
|
||||
value: () => collection[token.key].duration,
|
||||
oninput: (e) => (collection[token.key].duration = parseInt(e.target.value, 10)),
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{ className: "field-help m-b-10" },
|
||||
t.button({
|
||||
type: "button",
|
||||
className: () => `link-hint ${collection[token.key].secret ? "txt-success" : ""}`,
|
||||
textContent: "Invalidate all previously issued tokens",
|
||||
onclick: () => {
|
||||
// toggle
|
||||
if (collection[token.key].secret) {
|
||||
delete collection[token.key].secret;
|
||||
} else {
|
||||
collection[token.key].secret = app.utils.randomSecret(50);
|
||||
}
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user