merge newui branch

This commit is contained in:
Gani Georgiev
2026-04-18 16:29:34 +03:00
parent 58f605e90c
commit 4c44044c0c
804 changed files with 58660 additions and 56663 deletions

View File

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

View 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;
}

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

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

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

View 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.",
),
),
),
),
),
];
},
);
}

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

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

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

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

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

View 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;
}

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

View 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;
}

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

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

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

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

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

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

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

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

View 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;
}

View 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;
}

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