1449 lines
53 KiB
JavaScript
1449 lines
53 KiB
JavaScript
window.app = window.app || {};
|
|
window.app.modals = window.app.modals || {};
|
|
|
|
/**
|
|
* Opens a record upsert modal.
|
|
*
|
|
* @example
|
|
* ```js
|
|
* // create
|
|
* app.modals.openRecordUpsert(collection)
|
|
*
|
|
* // update
|
|
* app.modals.openRecordUpsert(collection, record)
|
|
* ```
|
|
*
|
|
* @param {Object} collection
|
|
* @param {Object} [record]
|
|
* @param {Object} [modalSettings]
|
|
*/
|
|
window.app.modals.openRecordUpsert = function(collection, record = null, modalSettings = {
|
|
// base modal events
|
|
onbeforeopen: null, // function(el) {},
|
|
onafteropen: null, // function(el) {},
|
|
onbeforeclose: null, // function(el) {},
|
|
onafterclose: null, // function(el) {},
|
|
// record specific events
|
|
onsave: null, // function(record, isNew) {},
|
|
ondelete: null, // function(record) {},
|
|
onduplicate: null, // function(record) {},
|
|
ontokensreset: null, // function(record) {},
|
|
onpasswordresetsend: null, // function(record) {},
|
|
onverificationsend: null, // function(record) {},
|
|
}) {
|
|
app.store.errors = null; // reset
|
|
|
|
const modal = recordUpsertModal(collection, record, modalSettings);
|
|
if (!modal) {
|
|
return;
|
|
}
|
|
|
|
document.body.appendChild(modal);
|
|
|
|
app.modals.open(modal);
|
|
};
|
|
|
|
const defaultRedactFields = ["expand"];
|
|
|
|
function redacted(record, redactFields = defaultRedactFields) {
|
|
// create redacted clone only if necessery
|
|
if (redactFields.find((f) => typeof record[f] !== "undefined")) {
|
|
record = Object.assign({}, record);
|
|
for (let f of redactFields) {
|
|
delete record[f];
|
|
}
|
|
}
|
|
|
|
return record;
|
|
}
|
|
|
|
function downloadJSON(record) {
|
|
record = redacted(record);
|
|
app.utils.downloadJSON(record, record.collectionName + "_" + record.id + ".json");
|
|
}
|
|
|
|
function copyJSON(record) {
|
|
record = redacted(record);
|
|
app.utils.copyToClipboard(JSON.stringify(record, null, 2));
|
|
app.toasts.success("Record copied to clipboard!");
|
|
}
|
|
|
|
function serializeRecord(record) {
|
|
if (!record) {
|
|
return "";
|
|
}
|
|
|
|
return JSON.stringify(redacted(record));
|
|
}
|
|
|
|
const TAB_MAIN = "main";
|
|
const TAB_AUTH_PROVIDERS = "authProviders";
|
|
|
|
// @todo consider exporting the "tabs" with the final version
|
|
function recordUpsertModal(collection, rawRecord, modalSettings) {
|
|
if (!collection?.id) {
|
|
console.warn("[recordUpsertModal] missing required collection");
|
|
return;
|
|
}
|
|
|
|
let modal;
|
|
|
|
const uniqueId = "record_upsert_" + app.utils.randomString();
|
|
|
|
const listingColumnsPreferences = app.utils.getLocalHistory(app.consts.COLUMNS_STORAGE_PREFIX + collection.id, {});
|
|
|
|
const data = store({
|
|
isLoading: true,
|
|
isSaving: false,
|
|
isLocked: false,
|
|
originalRecord: {},
|
|
record: {},
|
|
initialDraft: null,
|
|
activeTab: TAB_MAIN,
|
|
get isNew() {
|
|
return app.utils.isEmpty(data.originalRecord?.id);
|
|
},
|
|
get isAuthCollection() {
|
|
return collection.type == "auth";
|
|
},
|
|
get isSuperusersCollection() {
|
|
return collection.name == "_superusers";
|
|
},
|
|
get showTabs() {
|
|
return !data.isNew && data.isAuthCollection && !data.isSuperusersCollection;
|
|
},
|
|
get excludedFields() {
|
|
const result = ["id"];
|
|
|
|
if (data.isAuthCollection) {
|
|
result.push("email", "emailVisibility", "verified", "password", "tokenKey");
|
|
}
|
|
|
|
return result;
|
|
},
|
|
get initialDraftHash() {
|
|
return serializeRecord(data.initialDraft);
|
|
},
|
|
get recordHash() {
|
|
return serializeRecord(data.record);
|
|
},
|
|
get originalRecordHash() {
|
|
return serializeRecord(data.originalRecord);
|
|
},
|
|
get hasChanges() {
|
|
return data.originalRecordHash != data.recordHash;
|
|
},
|
|
get isFormDisabled() {
|
|
return data.isLoading || data.isSaving || (!data.isNew && !data.hasChanges);
|
|
},
|
|
});
|
|
|
|
// note: not a getter to avoid the microtask batching
|
|
function draftKey() {
|
|
return "draft_" + collection.id + "_" + (data.originalRecord?.id || "");
|
|
}
|
|
|
|
function getDraftHash() {
|
|
return window.localStorage.getItem(draftKey()) || "";
|
|
}
|
|
|
|
function getDraft() {
|
|
try {
|
|
const raw = getDraftHash();
|
|
if (raw) {
|
|
return JSON.parse(raw);
|
|
}
|
|
} catch (err) {
|
|
console.warn("getDraft failure:", err);
|
|
deleteDraft();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function saveDraft(serializedJSON) {
|
|
try {
|
|
window.localStorage.setItem(draftKey(), serializedJSON);
|
|
} catch (e) {
|
|
// ignore local storage errors in case the serialized data
|
|
// exceed the browser localStorage single value quota
|
|
console.warn("saveDraft failure:", e);
|
|
deleteDraft();
|
|
}
|
|
}
|
|
|
|
function deleteDraft() {
|
|
window.localStorage.removeItem(draftKey());
|
|
data.initialDraft = null;
|
|
}
|
|
|
|
function restoreDraft() {
|
|
if (!data.initialDraft) {
|
|
return;
|
|
}
|
|
|
|
if (app.store.errors) {
|
|
app.store.errors = null;
|
|
}
|
|
|
|
const draftClone = JSON.parse(JSON.stringify(data.initialDraft));
|
|
|
|
deleteDraft();
|
|
|
|
data.record = draftClone;
|
|
}
|
|
|
|
let draftWatcher;
|
|
|
|
function initDraftWatcher() {
|
|
data.initialDraft = getDraft();
|
|
|
|
draftWatcher?.unwatch();
|
|
draftWatcher = watch(() => data.recordHash, (newVal, oldVal) => {
|
|
if (typeof oldVal == "undefined") {
|
|
return;
|
|
}
|
|
|
|
if (data.hasChanges) {
|
|
saveDraft(data.recordHash);
|
|
} else {
|
|
deleteDraft();
|
|
}
|
|
});
|
|
}
|
|
|
|
async function initRecord(rawRecord) {
|
|
data.isLoading = true;
|
|
|
|
draftWatcher?.unwatch();
|
|
|
|
// normalize rawRecord (could be plain id string)
|
|
rawRecord = app.utils.isObject(rawRecord) ? rawRecord : { id: rawRecord || "" };
|
|
|
|
// new record
|
|
if (!rawRecord.id) {
|
|
data.originalRecord = JSON.parse(JSON.stringify(rawRecord));
|
|
data.record = JSON.parse(JSON.stringify(rawRecord));
|
|
|
|
data.isLoading = false;
|
|
data.isLocked = false;
|
|
initDraftWatcher();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
data.isLocked = !!app.store.settings?.meta?.hideControls;
|
|
|
|
// preload to minimize content jumps
|
|
data.originalRecord = JSON.parse(JSON.stringify(rawRecord));
|
|
data.record = JSON.parse(JSON.stringify(rawRecord));
|
|
|
|
// fetch to ensure that the main record fields are up-to-date
|
|
let record = await app.pb.collection(collection.name).getOne(rawRecord.id, {
|
|
requestKey: "upsert_load_" + rawRecord.id,
|
|
});
|
|
|
|
// preload existing expands (if any)
|
|
if (rawRecord.expand) {
|
|
record.expand = JSON.parse(JSON.stringify(rawRecord.expand));
|
|
}
|
|
|
|
// extend, not overwrite, to prevent reseting the reference passed down to the inputs
|
|
Object.assign(data.originalRecord, JSON.parse(JSON.stringify(record)));
|
|
Object.assign(data.record, JSON.parse(JSON.stringify(record)));
|
|
|
|
data.isLoading = false;
|
|
initDraftWatcher();
|
|
} catch (err) {
|
|
if (!err?.isAbort) {
|
|
app.checkApiError(err);
|
|
data.isLoading = false;
|
|
setTimeout(() => app.modals.close(modal, true), 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function exportPayload() {
|
|
const payload = {};
|
|
|
|
// shallow copy of the record fields
|
|
for (const prop in data.record) {
|
|
// skip expand and internal dynamic enumerable props
|
|
if (prop == "expand" || prop.startsWith("@@")) {
|
|
continue;
|
|
}
|
|
|
|
let val = data.record[prop]?.__raw || data.record[prop];
|
|
|
|
// normalize undefined values
|
|
if (typeof val == "undefined") {
|
|
val = null;
|
|
}
|
|
|
|
payload[prop] = val;
|
|
}
|
|
|
|
// apply fields save normalization funcs
|
|
for (const field of collection.fields) {
|
|
const saveHook = app.fieldTypes[field.type]?.onrecordsave;
|
|
if (!saveHook) {
|
|
continue;
|
|
}
|
|
|
|
await saveHook({
|
|
collection: collection,
|
|
originalRecord: data.originalRecord,
|
|
record: data.record,
|
|
field: field,
|
|
payload: payload,
|
|
});
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
async function save(close = true) {
|
|
if (data.isLocked || data.isSaving || (!data.isNew && !data.hasChanges)) {
|
|
return;
|
|
}
|
|
|
|
data.isSaving = true;
|
|
|
|
try {
|
|
const payload = await exportPayload();
|
|
|
|
const isNew = app.utils.isEmpty(data.originalRecord?.id);
|
|
|
|
let record;
|
|
if (isNew) {
|
|
record = await app.pb.collection(collection.name).create(payload);
|
|
} else {
|
|
record = await app.pb.collection(collection.name).update(data.originalRecord.id, payload);
|
|
}
|
|
|
|
deleteDraft();
|
|
|
|
if (isNew) {
|
|
// replace to ensure the same keys order and to force inputs rerender
|
|
data.originalRecord = structuredClone(record);
|
|
data.record = structuredClone(record);
|
|
} else {
|
|
// extend, not overwrite, to prevent reseting the reference passed down to the inputs
|
|
Object.assign(data.originalRecord, structuredClone(record));
|
|
Object.assign(data.record, structuredClone(record));
|
|
}
|
|
|
|
modalSettings.onsave?.(record, isNew);
|
|
|
|
// reset all errors
|
|
app.store.errors = null;
|
|
|
|
let msg;
|
|
if (isNew) {
|
|
msg = `Successfully created ${collection.name} "${record.id}".`;
|
|
} else {
|
|
msg = `Successfully updated ${collection.name} "${record.id}".`;
|
|
}
|
|
app.toasts.success(msg, { key: "recordSave" });
|
|
|
|
data.isSaving = false;
|
|
|
|
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 record.", { key: "recordSave" });
|
|
}
|
|
}
|
|
}
|
|
|
|
function resetForm() {
|
|
deleteDraft();
|
|
data.record = JSON.parse(JSON.stringify(data.originalRecord));
|
|
}
|
|
|
|
async function duplicate() {
|
|
const clone = data.originalRecord ? JSON.parse(JSON.stringify(data.originalRecord)) : {};
|
|
clone.id = "";
|
|
|
|
// apply fields duplicate hook
|
|
for (const field of collection.fields) {
|
|
const duplicateHook = app.fieldTypes[field.type]?.onrecordduplicate;
|
|
if (!duplicateHook) {
|
|
continue;
|
|
}
|
|
|
|
await duplicateHook({
|
|
collection: collection,
|
|
field: field,
|
|
originalRecord: data.originalRecord,
|
|
clone: clone,
|
|
});
|
|
}
|
|
|
|
deleteDraft();
|
|
|
|
modalSettings.onduplicate?.(clone);
|
|
|
|
initRecord(clone);
|
|
}
|
|
|
|
function mainTab() {
|
|
return [
|
|
t.div(
|
|
{ className: "modal-content" },
|
|
t.form(
|
|
{
|
|
id: uniqueId + "form",
|
|
className: "grid",
|
|
inert: () => data.isLoading || data.isSaving,
|
|
onsubmit: (e) => {
|
|
e.preventDefault();
|
|
// save(); // don't allow to prevent accidental save on input enter
|
|
},
|
|
onmount: (el) => {
|
|
el._quickSaveHandler = (e) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.code == "KeyS") {
|
|
e.preventDefault();
|
|
save(false);
|
|
}
|
|
};
|
|
window.addEventListener("keydown", el._quickSaveHandler);
|
|
},
|
|
onunmount: (el) => {
|
|
if (el?._quickSaveHandler) {
|
|
window.removeEventListener("keydown", el?._quickSaveHandler);
|
|
}
|
|
},
|
|
},
|
|
// draft alert
|
|
() => {
|
|
if (
|
|
data.isLoading
|
|
|| data.hasChanges
|
|
|| app.utils.isEmpty(data.initialDraft)
|
|
|| data.initialDraftHash == data.recordHash
|
|
) {
|
|
return;
|
|
}
|
|
|
|
return t.div(
|
|
{ className: "col-12" },
|
|
t.div(
|
|
{ className: "alert warning flex gap-sm" },
|
|
t.div({ className: "content" }, "The record has previous unsaved changes."),
|
|
t.button(
|
|
{
|
|
type: "button",
|
|
className: "btn sm outline",
|
|
onclick: () => restoreDraft(),
|
|
},
|
|
t.span({ className: "txt" }, "Restore draft"),
|
|
),
|
|
t.button(
|
|
{
|
|
type: "button",
|
|
className: "btn sm secondary transparent circle m-l-auto",
|
|
ariaLabel: app.attrs.tooltip("Discard draft", "left"),
|
|
onclick: () => {
|
|
deleteDraft();
|
|
},
|
|
},
|
|
t.i({ className: "ri-close-line", ariaHidden: true }),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
// primary key
|
|
() => {
|
|
const pkField = collection.fields?.find((f) => f.primaryKey);
|
|
|
|
return t.div(
|
|
{ className: "col-12" },
|
|
app.fieldTypes[pkField.type].input({
|
|
get collection() {
|
|
return collection;
|
|
},
|
|
get originalRecord() {
|
|
return data.originalRecord;
|
|
},
|
|
get record() {
|
|
return data.record;
|
|
},
|
|
get field() {
|
|
return pkField;
|
|
},
|
|
}),
|
|
);
|
|
},
|
|
// special collection fields
|
|
() => {
|
|
if (!data.isAuthCollection) {
|
|
return;
|
|
}
|
|
|
|
const result = [
|
|
t.div({ className: "col-12" }, authFieldEmail(collection, data)),
|
|
t.div({ className: "col-12" }, authFieldPassword(collection, data)),
|
|
];
|
|
|
|
// superusers are always verified
|
|
if (!data.isSuperusersCollection) {
|
|
result.push(
|
|
t.div({ className: "col-12" }, authFieldVerified(collection, data)),
|
|
);
|
|
}
|
|
|
|
return result;
|
|
},
|
|
// regular collection fields
|
|
() => {
|
|
const rows = [];
|
|
|
|
const excludedFields = data.excludedFields;
|
|
|
|
for (const field of collection.fields) {
|
|
if (!app.fieldTypes[field.type]?.input || excludedFields.includes(field.name)) {
|
|
continue;
|
|
}
|
|
|
|
rows.push(
|
|
t.div(
|
|
// blur if not hidden and not explicitly toggle-on
|
|
{
|
|
className: () =>
|
|
`col-12 ${
|
|
field.hidden && !listingColumnsPreferences[field.id]
|
|
? "hidden-field-blur"
|
|
: ""
|
|
}`,
|
|
},
|
|
() => {
|
|
return app.fieldTypes[field.type].input({
|
|
get collection() {
|
|
return collection;
|
|
},
|
|
get originalRecord() {
|
|
return data.originalRecord;
|
|
},
|
|
get record() {
|
|
return data.record;
|
|
},
|
|
get field() {
|
|
return field;
|
|
},
|
|
});
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
if (rows.length && data.isAuthCollection) {
|
|
rows.unshift(t.div({ className: "col-12" }, t.hr({ className: "m-0" })));
|
|
}
|
|
|
|
return rows;
|
|
},
|
|
),
|
|
),
|
|
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"),
|
|
),
|
|
t.button(
|
|
{
|
|
hidden: () => !data.isLocked,
|
|
type: "button",
|
|
className: "btn outline",
|
|
disabled: () => data.isFormDisabled,
|
|
onclick: () => data.isLocked = false,
|
|
},
|
|
t.i({ className: "ri-lock-unlock-line", ariaHidden: true }),
|
|
t.span({ className: "txt" }, "Unlock to save"),
|
|
),
|
|
t.div(
|
|
{
|
|
hidden: () => data.isLocked,
|
|
className: "btns",
|
|
},
|
|
t.button(
|
|
{
|
|
type: "button",
|
|
className: () => `btn expanded-lg ${data.isLoading || data.isSaving ? "loading" : ""}`,
|
|
disabled: () => data.isLocked || data.isFormDisabled,
|
|
onclick: () => save(),
|
|
},
|
|
t.span({ className: "txt" }, () => (data.isNew ? "Create" : "Save changes")),
|
|
),
|
|
t.button(
|
|
{
|
|
type: "button",
|
|
className: () => `btn p-5`,
|
|
title: "Save options",
|
|
disabled: () => data.isLocked || data.isFormDisabled,
|
|
"html-popovertarget": uniqueId + "save_options",
|
|
},
|
|
t.i({ className: "ri-arrow-up-s-line", ariaHidden: true }),
|
|
),
|
|
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();
|
|
save(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"),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
];
|
|
}
|
|
|
|
modal = t.div(
|
|
{
|
|
pbEvent: "recordUpsertModal",
|
|
className: "modal record-upsert-modal",
|
|
onbeforeopen: () => {
|
|
initRecord(rawRecord);
|
|
|
|
return modalSettings.onbeforeopen?.(el);
|
|
},
|
|
onafteropen: (el) => {
|
|
modalSettings.onafteropen?.(el);
|
|
},
|
|
onbeforeclose: (el, forceClosed) => {
|
|
if (forceClosed) {
|
|
return modalSettings.onbeforeclose?.(el);
|
|
}
|
|
|
|
if (data.isLoading || 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?",
|
|
() => {
|
|
deleteDraft();
|
|
return r(modalSettings.onbeforeclose?.(el));
|
|
},
|
|
() => r(false),
|
|
{ yesButton: "Yes, discard" },
|
|
);
|
|
});
|
|
},
|
|
onafterclose: (el) => {
|
|
modalSettings.onafterclose?.(el);
|
|
el?.remove();
|
|
},
|
|
onunmount: () => {
|
|
draftWatcher?.unwatch();
|
|
},
|
|
},
|
|
t.header(
|
|
{ className: () => `modal-header ${data.showTabs ? "isolated" : ""}` },
|
|
t.div(
|
|
{ className: "grid" },
|
|
t.div(
|
|
{ className: "col-12 flex" },
|
|
t.h6(
|
|
{ className: "modal-title" },
|
|
t.span(null, () => (data.isNew ? "Create " : "Edit ")),
|
|
t.strong(
|
|
{ className: "txt-ellipsis collection-name", style: "max-width: 220px" },
|
|
() => collection.name,
|
|
),
|
|
t.span(null, " record"),
|
|
),
|
|
t.div({ className: "flex-fill" }),
|
|
() => {
|
|
if (app.utils.isEmpty(data.originalRecord?.id)) {
|
|
return;
|
|
}
|
|
|
|
return [
|
|
t.button(
|
|
{
|
|
type: "button",
|
|
title: "More options",
|
|
className: () => `btn sm circle transparent ${data.isLoading ? "loading" : ""}`,
|
|
disabled: () => data.isLoading,
|
|
"html-popovertarget": uniqueId + "modal-header-dropdown",
|
|
},
|
|
t.i({ className: "ri-more-line", ariaHidden: true }),
|
|
),
|
|
t.div(
|
|
{
|
|
id: uniqueId + "modal-header-dropdown",
|
|
className: "dropdown nowrap modal-header-dropdown",
|
|
popover: "auto",
|
|
},
|
|
// auth only options
|
|
() => {
|
|
if (!data.isAuthCollection) {
|
|
return;
|
|
}
|
|
|
|
const options = [];
|
|
|
|
if (
|
|
!data.originalRecord.verified
|
|
&& data.originalRecord.email
|
|
// superusers are always verified
|
|
&& !data.isSuperusersCollection
|
|
) {
|
|
options.push(sendVerificationDropdownItem(collection, data, modalSettings));
|
|
}
|
|
|
|
if (data.originalRecord.email) {
|
|
options.push(
|
|
sendPasswordResetEmailDropdownItem(collection, data, modalSettings),
|
|
);
|
|
}
|
|
|
|
options.push(impersonateDropdownItem(collection, data, modalSettings));
|
|
options.push(resetTokenKeyDropdownItem(collection, data, modalSettings));
|
|
options.push(t.hr());
|
|
|
|
return options;
|
|
},
|
|
t.button(
|
|
{
|
|
type: "button",
|
|
className: "dropdown-item",
|
|
onclick: (e) => {
|
|
e.target.closest(".dropdown").hidePopover();
|
|
copyJSON(data.originalRecord);
|
|
},
|
|
},
|
|
t.i({ className: "ri-braces-line", ariaHidden: true }),
|
|
t.span({ className: "txt" }, "Copy JSON"),
|
|
),
|
|
() => {
|
|
if (collection.type == "view") {
|
|
return;
|
|
}
|
|
|
|
return [
|
|
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", ariaHidden: true }),
|
|
t.span({ className: "txt" }, "Duplicate"),
|
|
),
|
|
t.hr(),
|
|
deleteDropdownItem(collection, data, modalSettings),
|
|
];
|
|
},
|
|
),
|
|
];
|
|
},
|
|
),
|
|
() => {
|
|
if (!data.showTabs) {
|
|
return;
|
|
}
|
|
|
|
return t.div(
|
|
{ className: "col-12" },
|
|
t.nav(
|
|
{ className: "tabs-header equal-width" },
|
|
t.button(
|
|
{
|
|
type: "button",
|
|
disabled: () => data.isLoading || data.isSaving,
|
|
className: () =>
|
|
`tab-item ${
|
|
data.activeTab == TAB_MAIN ? "active" : data.hasChanges ? "txt-warning" : ""
|
|
}`,
|
|
ariaDescription: app.attrs.tooltip(() =>
|
|
data.hasChanges && data.activeTab != TAB_MAIN ? "Has unsaved changes" : ""
|
|
),
|
|
onclick: () => data.activeTab = TAB_MAIN,
|
|
},
|
|
t.span({ className: "txt" }, () => (data.isAuthCollection ? "Account" : "Main")),
|
|
),
|
|
t.button(
|
|
{
|
|
type: "button",
|
|
disabled: () => data.isLoading || data.isSaving,
|
|
className: () => `tab-item ${data.activeTab == TAB_AUTH_PROVIDERS ? "active" : ""}`,
|
|
onclick: () => data.activeTab = TAB_AUTH_PROVIDERS,
|
|
},
|
|
t.span({ className: "txt" }, "Auth providers"),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
() => {
|
|
if (
|
|
!data.isNew
|
|
&& !data.isSuperusersCollection
|
|
&& data.activeTab == TAB_AUTH_PROVIDERS
|
|
) {
|
|
return authProvidersTab(collection, data);
|
|
}
|
|
|
|
return mainTab();
|
|
},
|
|
);
|
|
|
|
return modal;
|
|
}
|
|
|
|
// dropdown options
|
|
// -------------------------------------------------------------------
|
|
|
|
function resetTokenKeyDropdownItem(collection, data, modalSettings) {
|
|
const local = store({
|
|
isSubmitting: false,
|
|
});
|
|
|
|
async function resetTokenKey() {
|
|
if (local.isSubmitting || !data.record.id) {
|
|
return;
|
|
}
|
|
|
|
local.isSubmitting = true;
|
|
|
|
try {
|
|
const payload = {};
|
|
|
|
const tokenKeyField = collection.fields.find((f) => f.name == "tokenKey");
|
|
if (tokenKeyField.autogeneratePattern) {
|
|
// leave the server to create the random string
|
|
payload["tokenKey:autogenerate"] = "";
|
|
} else {
|
|
// set it manually
|
|
payload["tokenKey"] = app.utils.randomSecret(
|
|
tokenKeyField.max << 0 || Math.max(2 * tokenKeyField.min << 0, 50),
|
|
);
|
|
}
|
|
|
|
const updatedRecord = await app.pb.collection(collection.name).update(data.record.id, payload);
|
|
|
|
modalSettings.ontokensreset?.(updatedRecord);
|
|
|
|
// refresh all autodate fields
|
|
const fields = collection.fields?.filter((f) => f.type == "autodate") || [];
|
|
for (const field of fields) {
|
|
const val = updatedRecord[field.name];
|
|
if (data.initialDraft) {
|
|
data.initialDraft[field.name] = val;
|
|
}
|
|
data.originalRecord[field.name] = val;
|
|
data.record[field.name] = val;
|
|
}
|
|
|
|
app.toasts.success("Successfully reset all tokens for the selected record.");
|
|
} catch (err) {
|
|
app.checkApiError(err);
|
|
}
|
|
|
|
local.isSubmitting = false;
|
|
}
|
|
|
|
return t.button(
|
|
{
|
|
type: "button",
|
|
className: "dropdown-item",
|
|
disabled: () => local.isSubmitting,
|
|
onclick: (e) => {
|
|
e.target.closest(".dropdown").hidePopover();
|
|
app.modals.confirm(
|
|
"Do you really want to reset all issued tokens for the selected auth record?",
|
|
resetTokenKey,
|
|
null,
|
|
{ yesButton: "Reset all tokens" },
|
|
);
|
|
},
|
|
},
|
|
t.i({ className: "ri-reset-left-line", ariaHidden: true }),
|
|
t.span({ className: "txt" }, "Reset issued tokens"),
|
|
);
|
|
}
|
|
|
|
function sendPasswordResetEmailDropdownItem(collection, data, modalSettings) {
|
|
const local = store({
|
|
isSubmitting: false,
|
|
});
|
|
|
|
async function sendPasswordResetEmail() {
|
|
if (local.isSubmitting || !data.originalRecord?.email) {
|
|
return;
|
|
}
|
|
|
|
local.isSubmitting = true;
|
|
|
|
try {
|
|
await app.pb.collection(collection.name).requestPasswordReset(data.originalRecord.email);
|
|
|
|
modalSettings.onpasswordresetsend?.(JSON.parse(JSON.stringify(data.originalRecord)));
|
|
|
|
app.toasts.success(`Successfully sent password reset email to ${data.originalRecord.email}.`);
|
|
} catch (err) {
|
|
app.checkApiError(err);
|
|
}
|
|
|
|
local.isSubmitting = false;
|
|
}
|
|
|
|
return t.button(
|
|
{
|
|
type: "button",
|
|
className: "dropdown-item",
|
|
disabled: () => local.isSubmitting,
|
|
onclick: (e) => {
|
|
e.target.closest(".dropdown").hidePopover();
|
|
app.modals.confirm(
|
|
`Do you really want to send password reset email to ${data.originalRecord?.email}?`,
|
|
sendPasswordResetEmail,
|
|
null,
|
|
{ yesButton: "Send" },
|
|
);
|
|
},
|
|
},
|
|
t.i({ className: "ri-mail-lock-line", ariaHidden: true }),
|
|
t.span({ className: "txt" }, "Send password reset email"),
|
|
);
|
|
}
|
|
|
|
function sendVerificationDropdownItem(collection, data, modalSettings) {
|
|
const local = store({
|
|
isSubmitting: false,
|
|
});
|
|
|
|
async function sendVerificationEmail() {
|
|
if (local.isSubmitting || !data.originalRecord?.email || data.originalRecord?.verified) {
|
|
return;
|
|
}
|
|
|
|
local.isSubmitting = true;
|
|
|
|
try {
|
|
await app.pb.collection(collection.name).requestVerification(data.originalRecord.email);
|
|
|
|
modalSettings.onverificationsend?.(JSON.parse(JSON.stringify(data.originalRecord)));
|
|
|
|
app.toasts.success(`Successfully sent verification email to ${data.originalRecord.email}.`);
|
|
} catch (err) {
|
|
app.checkApiError(err);
|
|
}
|
|
|
|
local.isSubmitting = false;
|
|
}
|
|
|
|
return t.button(
|
|
{
|
|
type: "button",
|
|
className: "dropdown-item",
|
|
disabled: () => local.isSubmitting,
|
|
onclick: (e) => {
|
|
e.target.closest(".dropdown").hidePopover();
|
|
app.modals.confirm(
|
|
`Do you really want to send verification email to ${data.originalRecord?.email}?`,
|
|
sendVerificationEmail,
|
|
null,
|
|
{ yesButton: "Send" },
|
|
);
|
|
},
|
|
},
|
|
t.i({ className: "ri-mail-check-line", ariaHidden: true }),
|
|
t.span({ className: "txt" }, "Send verification email"),
|
|
);
|
|
}
|
|
|
|
function impersonateDropdownItem(collection, data, modalSettings) {
|
|
return t.button(
|
|
{
|
|
type: "button",
|
|
className: "dropdown-item",
|
|
onclick: (e) => {
|
|
e.target.closest(".dropdown").hidePopover();
|
|
app.modals.openRecordImpersontate(data.originalRecord);
|
|
},
|
|
},
|
|
t.i({ className: "ri-id-card-line", ariaHidden: true }),
|
|
t.span({ className: "txt" }, "Impersonate"),
|
|
);
|
|
}
|
|
|
|
function deleteDropdownItem(collection, data, modalSettings) {
|
|
const local = store({
|
|
isSubmitting: false,
|
|
});
|
|
|
|
async function deleteRecord() {
|
|
if (local.isSubmitting || !data.originalRecord?.id) {
|
|
return;
|
|
}
|
|
|
|
local.isSubmitting = true;
|
|
|
|
try {
|
|
await app.pb.collection(collection.name).delete(data.originalRecord.id);
|
|
|
|
modalSettings.ondelete?.(JSON.parse(JSON.stringify(data.originalRecord)));
|
|
|
|
app.toasts.success(`Successfully deleted record "${data.originalRecord.id}".`);
|
|
} catch (err) {
|
|
app.checkApiError(err);
|
|
}
|
|
|
|
local.isSubmitting = false;
|
|
}
|
|
|
|
return t.button(
|
|
{
|
|
type: "button",
|
|
className: "dropdown-item txt-danger",
|
|
disabled: () => local.isSubmitting,
|
|
onclick: (e) => {
|
|
e.target.closest(".dropdown").hidePopover();
|
|
app.modals.confirm(
|
|
`Do you really want to delete the selected record?`,
|
|
async () => {
|
|
await deleteRecord();
|
|
app.modals.close(e.target.closest(".modal"));
|
|
},
|
|
null,
|
|
{ yesButton: "Delete record" },
|
|
);
|
|
},
|
|
},
|
|
t.i({ className: "ri-delete-bin-7-line", ariaHidden: true }),
|
|
t.span({ className: "txt" }, "Delete"),
|
|
);
|
|
}
|
|
|
|
// auth specific fields
|
|
// -------------------------------------------------------------------
|
|
|
|
function authFieldEmail(collection, data) {
|
|
const emailField = collection.fields.find((f) => f.name == "email");
|
|
if (!emailField) {
|
|
console.warn("missing expected email field");
|
|
return;
|
|
}
|
|
|
|
const uniqueId = "auth_email_" + app.utils.randomString();
|
|
|
|
return t.div(
|
|
{ className: "record-field-input field-type-email field-type-auth-email" },
|
|
t.div(
|
|
{ className: "fields" },
|
|
t.div(
|
|
{ className: "field" },
|
|
t.label(
|
|
{ htmlFor: uniqueId },
|
|
t.i({ className: app.fieldTypes.email.icon, ariaHidden: true }),
|
|
t.span({ className: "txt" }, () => emailField.name),
|
|
),
|
|
t.input({
|
|
type: "email",
|
|
id: uniqueId,
|
|
spellcheck: false,
|
|
name: () => emailField.name,
|
|
required: () => emailField.required,
|
|
value: () => data.record[emailField.name] || "",
|
|
oninput: (e) => (data.record[emailField.name] = e.target.value),
|
|
}),
|
|
),
|
|
t.div(
|
|
{ className: "field addon" },
|
|
t.button(
|
|
{
|
|
type: "button",
|
|
className: () => `btn sm transparent ${data.record.emailVisibility ? "success" : "secondary"}`,
|
|
ariaDescription: app.attrs.tooltip("Make email public or private", "top-right"),
|
|
onclick: () => {
|
|
data.record.emailVisibility = !data.record.emailVisibility;
|
|
},
|
|
},
|
|
t.span({ className: "txt" }, "Public: ", () => (data.record.emailVisibility ? "On" : "Off")),
|
|
),
|
|
),
|
|
),
|
|
() => {
|
|
if (emailField.help) {
|
|
return t.div({ className: "field-help" }, emailField.help);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
function authFieldVerified(collection, data) {
|
|
const verifiedField = collection.fields.find((f) => f.name == "verified");
|
|
if (!verifiedField) {
|
|
console.warn("missing expected verified field");
|
|
return;
|
|
}
|
|
|
|
const elem = app.fieldTypes.bool.input({
|
|
get field() {
|
|
return verifiedField;
|
|
},
|
|
get collection() {
|
|
return collection;
|
|
},
|
|
get record() {
|
|
return data.record;
|
|
},
|
|
get originalRecord() {
|
|
return data.originalRecord;
|
|
},
|
|
});
|
|
|
|
elem.addEventListener("change", (e) => {
|
|
if (data.originalRecord.verified == data.record.verified) {
|
|
return;
|
|
}
|
|
|
|
app.modals.confirm(
|
|
`Do you really want to manually change the verified account state from "${!data.record
|
|
.verified}" to "${data.record.verified}"?`,
|
|
null,
|
|
() => {
|
|
data.record.verified = !data.record.verified;
|
|
},
|
|
{ yesButton: "Yes, " + (data.record.verified ? "verify" : "unverify") },
|
|
);
|
|
});
|
|
|
|
return elem;
|
|
}
|
|
|
|
function authFieldPassword(collection, data) {
|
|
const uniqueId = "auth_pass_" + app.utils.randomString();
|
|
|
|
const local = store({
|
|
changePassword: false,
|
|
get isNew() {
|
|
return app.utils.isEmpty(data.originalRecord?.id);
|
|
},
|
|
});
|
|
|
|
function clearPasswords() {
|
|
delete data.record.password;
|
|
delete data.record.passwordConfirm;
|
|
|
|
if (app.store.errors) {
|
|
delete app.store.errors.password;
|
|
delete app.store.errors.passwordConfirm;
|
|
}
|
|
}
|
|
|
|
return t.div(
|
|
{
|
|
className: "record-field-input field-type-password field-type-auth-password",
|
|
onmount: (el) => {
|
|
el._watchers?.forEach((w) => w?.unwatch());
|
|
el._watchers = [
|
|
// force the toggle if any of the fields are populated
|
|
// (e.g. record update from outside) or there is an error
|
|
watch(() => {
|
|
if (local.changePassword) {
|
|
return; // already enabled
|
|
}
|
|
|
|
if (
|
|
app.store.errors?.password
|
|
|| app.store.errors?.passwordConfirm
|
|
|| data.record.password
|
|
|| data.record.passwordConfirm
|
|
) {
|
|
local.changePassword = true;
|
|
}
|
|
}),
|
|
];
|
|
},
|
|
onunmount: (el) => {
|
|
el._watchers?.forEach((w) => w?.unwatch());
|
|
},
|
|
},
|
|
t.div(
|
|
{
|
|
hidden: () => local.isNew,
|
|
className: "field",
|
|
},
|
|
t.input({
|
|
type: "checkbox",
|
|
id: uniqueId + "_change",
|
|
className: "switch",
|
|
checked: () => local.changePassword,
|
|
onchange: (e) => {
|
|
local.changePassword = e.target.checked;
|
|
if (!e.target.checked) {
|
|
clearPasswords();
|
|
}
|
|
},
|
|
}),
|
|
t.label({ htmlFor: uniqueId + "_change" }, t.span({ className: "txt" }, "change password")),
|
|
),
|
|
app.components.slide(
|
|
() => local.isNew || local.changePassword,
|
|
t.div(
|
|
{ className: () => `fields ${local.isNew ? "" : "m-t-sm"}` },
|
|
t.div(
|
|
{ className: "field" },
|
|
t.label(
|
|
{ htmlFor: uniqueId + "_password" },
|
|
t.i({ className: "ri-lock-line", ariaHidden: true }),
|
|
t.span({ className: "txt" }, "Password"),
|
|
),
|
|
t.input({
|
|
type: "password",
|
|
id: uniqueId + "_password",
|
|
spellcheck: false,
|
|
name: "password",
|
|
className: "inline-error",
|
|
autocomplete: "new-password",
|
|
required: () => local.isNew || local.changePassword,
|
|
value: () => data.record.password || "",
|
|
oninput: (e) => {
|
|
if (!e.target.value) {
|
|
// delete to ensure that it is not submitted
|
|
delete data.record.password;
|
|
} else {
|
|
data.record.password = e.target.value;
|
|
}
|
|
},
|
|
}),
|
|
),
|
|
t.div({ className: "delimiter" }),
|
|
t.div(
|
|
{ className: "field" },
|
|
t.label(
|
|
{ htmlFor: uniqueId + "_password_confirm" },
|
|
t.i({ className: "ri-lock-line", ariaHidden: true }),
|
|
t.span({ className: "txt" }, "Confirm"),
|
|
),
|
|
t.input({
|
|
type: "password",
|
|
id: uniqueId + "_password_confirm",
|
|
spellcheck: false,
|
|
name: "passwordConfirm",
|
|
className: "inline-error",
|
|
autocomplete: "new-password",
|
|
required: () => local.isNew || local.changePassword,
|
|
value: () => data.record.passwordConfirm || "",
|
|
oninput: (e) => {
|
|
if (!e.target.value) {
|
|
// delete to ensure that it is not submitted
|
|
delete data.record.passwordConfirm;
|
|
} else {
|
|
data.record.passwordConfirm = e.target.value;
|
|
}
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
() => {
|
|
const helpText = collection.fields?.find((f) => f.name == "password")?.help || "";
|
|
if (!helpText) {
|
|
return;
|
|
}
|
|
|
|
return t.div({ className: "field-help" }, helpText);
|
|
},
|
|
t.div(
|
|
{ className: "field-help" },
|
|
t.span(
|
|
{
|
|
className: "txt link-hint",
|
|
role: "button",
|
|
onclick: (e) => {
|
|
e.preventDefault();
|
|
const random = app.utils.randomSecret(20);
|
|
data.record.password = random;
|
|
data.record.passwordConfirm = random;
|
|
app.utils.copyToClipboard(random);
|
|
app.toasts.info("Generated and copied random password to clipboard.");
|
|
},
|
|
},
|
|
"Generate and set random password",
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
function authProvidersTab(collection, data) {
|
|
const local = store({
|
|
isLoading: false,
|
|
externalAuths: [],
|
|
});
|
|
|
|
async function loadExternalAuths() {
|
|
local.isLoading = true;
|
|
|
|
try {
|
|
local.externalAuths = await app.pb.collection("_externalAuths").getFullList({
|
|
filter: app.pb.filter("collectionRef={:collectionId} && recordRef={:recordId}", {
|
|
collectionId: data.record.collectionId,
|
|
recordId: data.record.id,
|
|
}),
|
|
});
|
|
|
|
local.isLoading = false;
|
|
} catch (err) {
|
|
if (err?.isAbort) {
|
|
app.pb.checkApiError(err);
|
|
local.isLoading = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
function confirmAndUnlink(externalAuth) {
|
|
const providerInfo = app.store.oauth2Providers?.find((p) => p.name == externalAuth.provider) || {};
|
|
const name = providerInfo.displayName || externalAuth.provider;
|
|
|
|
app.modals.confirm(
|
|
`Do you really want to unlink the ${name} provider?`,
|
|
() => {
|
|
return app.pb
|
|
.collection("_externalAuths")
|
|
.delete(externalAuth.id)
|
|
.then(() => {
|
|
app.toasts.success(`Successfully unlinked ${name}.`);
|
|
loadExternalAuths(); // reload list
|
|
})
|
|
.catch((err) => {
|
|
app.checkApiError(err);
|
|
});
|
|
},
|
|
null,
|
|
{ yesButton: "Unlink" },
|
|
);
|
|
}
|
|
|
|
return [
|
|
t.div(
|
|
{ className: "modal-content" },
|
|
t.div(
|
|
{
|
|
className: "list",
|
|
onmount: () => {
|
|
loadExternalAuths();
|
|
},
|
|
},
|
|
() => {
|
|
if (local.isLoading) {
|
|
return t.div({ className: "list-item" }, t.div({ className: "skeleton-loader" }));
|
|
}
|
|
|
|
if (!local.externalAuths.length) {
|
|
return t.div(
|
|
{ className: "list-item" },
|
|
t.div({ className: "block txt-hint txt-center" }, "No external auth providers found."),
|
|
);
|
|
}
|
|
|
|
return local.externalAuths.map((externalAuth) => {
|
|
const providerInfo = app.store.oauth2Providers?.find((p) => p.name == externalAuth.provider)
|
|
|| {};
|
|
|
|
return t.div(
|
|
{ className: "list-item" },
|
|
t.figure(
|
|
{ className: "provider-logo" },
|
|
() => {
|
|
if (providerInfo.logo) {
|
|
return t.img({
|
|
src: "data:image/svg+xml;base64," + btoa(providerInfo.logo),
|
|
alt: externalAuth.provider + " logo",
|
|
});
|
|
}
|
|
|
|
return t.i({ className: app.utils.fallbackProviderIcon, ariaHidden: true });
|
|
},
|
|
),
|
|
t.div(
|
|
{ className: "content" },
|
|
t.span(
|
|
{ className: "txt-nowrap" },
|
|
() => providerInfo.displayName || externalAuth.provider,
|
|
),
|
|
t.small({ className: "txt-hint" }, "ID: ", () => externalAuth.providerId),
|
|
),
|
|
t.div(
|
|
{ className: "actions" },
|
|
t.button(
|
|
{
|
|
type: "button",
|
|
className: "btn sm secondary transparent circle",
|
|
ariaLabel: app.attrs.tooltip("Unlink", "left"),
|
|
onclick: () => confirmAndUnlink(externalAuth),
|
|
},
|
|
t.i({ className: "ri-close-line", ariaHidden: true }),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
},
|
|
),
|
|
),
|
|
t.footer(
|
|
{ className: "modal-footer" },
|
|
t.button(
|
|
{
|
|
type: "button",
|
|
className: "btn transparent m-r-auto",
|
|
onclick: () => app.modals.close(),
|
|
},
|
|
t.span({ className: "txt" }, "Close"),
|
|
),
|
|
),
|
|
];
|
|
}
|