Files
pocketbase/ui/src/records/recordUpsertModal.js
2026-04-23 14:12:59 +03:00

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