/**
* Auth / Admin
* @type {module:express-promise-router}
*/
const Router = require("express-promise-router");
const { contract, is } = require("contractis");
const db = require("@saltcorn/data/db");
const User = require("@saltcorn/data/models/user");
const View = require("@saltcorn/data/models/view");
const Field = require("@saltcorn/data/models/field");
const Form = require("@saltcorn/data/models/form");
const {
mkTable,
renderForm,
link,
post_btn,
settingsDropdown,
post_dropdown_item,
} = require("@saltcorn/markup");
const {
isAdmin,
setTenant,
error_catcher,
} = require("../routes/utils");
const { send_reset_email } = require("./resetpw");
const { getState } = require("@saltcorn/data/db/state");
const {
a,
div,
text,
span,
code,
h5,
i,
p,
} = require("@saltcorn/markup/tags");
const Table = require("@saltcorn/data/models/table");
const {
send_users_page,
config_fields_form,
save_config_from_form,
getBaseDomain,
hostname_matches_baseurl,
is_hsts_tld,
} = require("../markup/admin");
const { send_verification_email } = require("@saltcorn/data/models/email");
const router = new Router();
module.exports = router;
const getUserFields = async () => {
const userTable = await Table.findOne({ name: "users" });
const userFields = (await userTable.getFields()).filter(
(f) => !f.calculated && f.name !== "id"
);
const iterForm = async (cfgField) => {
const signup_form_name = getState().getConfig(cfgField, "");
if (signup_form_name) {
const signup_form = await View.findOne({ name: signup_form_name });
if (signup_form) {
(signup_form.configuration.columns || []).forEach((f) => {
const uf = userFields.find((uff) => uff.name === f.field_name);
if (uf) {
uf.fieldview = f.fieldview;
uf.attributes = { ...f.configuration, ...uf.attributes };
}
});
}
}
};
await iterForm("signup_form");
await iterForm("new_user_form");
return userFields;
};
const userForm = contract(
is.fun(
[is.obj({}), is.maybe(is.class("User"))],
is.promise(is.class("Form"))
),
async (req, user) => {
const roleField = new Field({
label: req.__("Role"),
name: "role_id",
type: "Key",
reftable_name: "roles",
});
const roles = (await User.get_roles()).filter((r) => r.role !== "public");
roleField.options = roles.map((r) => ({ label: r.role, value: r.id }));
const can_reset = getState().getConfig("smtp_host", "") !== "";
const userFields = await getUserFields();
const form = new Form({
fields: [roleField, ...userFields],
action: "/useradmin/save",
submitLabel: user ? req.__("Save") : req.__("Create"),
});
if (!user) {
form.fields.push(
new Field({
label: req.__("Set random password"),
name: "rnd_password",
type: "Bool",
default: true,
})
);
form.fields.push(
new Field({
label: req.__("Password"),
name: "password",
input_type: "password",
showIf: { rnd_password: false },
})
);
can_reset &&
form.fields.push(
new Field({
label: req.__("Send password reset email"),
name: "send_pwreset_email",
type: "Bool",
default: true,
showIf: { rnd_password: true },
})
);
}
if (user) {
form.hidden("id");
form.values = user;
delete form.values.password;
} else {
form.values.role_id = roles[roles.length - 1].id;
}
return form;
}
);
/**
* Dropdown for User Info in left menu
* @param user
* @param req
* @param can_reset
* @returns {string}
*/
const user_dropdown = (user, req, can_reset) =>
settingsDropdown(`dropdownMenuButton${user.id}`, [
a(
{
class: "dropdown-item",
href: `/useradmin/${user.id}`,
},
'<i class="fas fa-edit"></i> ' + req.__("Edit")
),
post_dropdown_item(
`/useradmin/set-random-password/${user.id}`,
'<i class="fas fa-random"></i> ' + req.__("Set random password"),
req
),
can_reset &&
post_dropdown_item(
`/useradmin/reset-password/${user.id}`,
'<i class="fas fa-envelope"></i> ' +
req.__("Send password reset email"),
req
),
can_reset &&
!user.verified_on &&
getState().getConfig("verification_view", "") &&
post_dropdown_item(
`/useradmin/send-verification/${user.id}`,
'<i class="fas fa-envelope"></i> ' +
req.__("Send verification email"),
req
),
user.disabled &&
post_dropdown_item(
`/useradmin/enable/${user.id}`,
'<i class="fas fa-play"></i> ' + req.__("Enable"),
req
),
!user.disabled &&
post_dropdown_item(
`/useradmin/disable/${user.id}`,
'<i class="fas fa-pause"></i> ' + req.__("Disable"),
req
),
div({ class: "dropdown-divider" }),
post_dropdown_item(
`/useradmin/delete/${user.id}`,
'<i class="far fa-trash-alt"></i> ' + req.__("Delete"),
req,
true,
user.email
),
]);
router.get(
"/",
setTenant,
isAdmin,
error_catcher(async (req, res) => {
const users = await User.find({}, { orderBy: "id" });
const roles = await User.get_roles();
var roleMap = {};
roles.forEach((r) => {
roleMap[r.id] = r.role;
});
const can_reset = getState().getConfig("smtp_host", "") !== "";
send_users_page({
res,
req,
active_sub: "Users",
contents: {
type: "card",
title: req.__("Users"),
contents: [
mkTable(
[
{ label: req.__("ID"), key: "id" },
{
label: req.__("Email"),
key: (r) => link(`/useradmin/${r.id}`, r.email),
},
{
label: "",
key: (r) =>
r.disabled
? span({ class: "badge badge-danger" }, "Disabled")
: "",
},
{
label: req.__("Verified"),
key: (r) =>
!!r.verified_on
? i({
class: "fas fa-check-circle text-success",
})
: "",
},
{ label: req.__("Role"), key: (r) => roleMap[r.role_id] },
{
label: "",
key: (r) => user_dropdown(r, req, can_reset),
},
],
users,
{ hover: true }
),
link(`/useradmin/new`, req.__("Create user")),
],
},
});
})
);
router.get(
"/new",
setTenant,
isAdmin,
error_catcher(async (req, res) => {
const form = await userForm(req);
send_users_page({
res,
req,
active_sub: "Users",
sub2_page: "New",
contents: {
type: "card",
title: req.__("New user"),
contents: [renderForm(form, req.csrfToken())],
},
});
})
);
const user_settings_form = (req) =>
config_fields_form({
req,
field_names: [
"allow_signup",
"login_menu",
"new_user_form",
"login_form",
"signup_form",
"user_settings_form",
"verification_view",
"elevate_verified",
"min_role_upload",
"timeout",
"email_mask",
"allow_forgot",
"cookie_sessions",
"custom_http_headers",
],
action: "/useradmin/settings",
submitLabel: req.__("Save"),
});
router.get(
"/settings",
setTenant,
isAdmin,
error_catcher(async (req, res) => {
const form = await user_settings_form(req);
send_users_page({
res,
req,
active_sub: "Settings",
contents: {
type: "card",
title: req.__("Authentication settings"),
contents: [renderForm(form, req.csrfToken())],
},
});
})
);
router.post(
"/settings",
setTenant,
isAdmin,
error_catcher(async (req, res) => {
const form = await user_settings_form(req);
form.validate(req.body);
if (form.hasErrors) {
send_users_page({
res,
req,
active_sub: "Settings",
contents: {
type: "card",
title: req.__("Authentication settings"),
contents: [renderForm(form, req.csrfToken())],
},
});
} else {
await save_config_from_form(form);
req.flash("success", req.__("User settings updated"));
res.redirect("/useradmin/settings");
}
})
);
router.get(
"/ssl",
setTenant,
isAdmin,
error_catcher(async (req, res) => {
const isRoot = db.getTenantSchema() === db.connectObj.default_schema;
if (!isRoot) {
req.flash(
"warning",
req.__("SSL settings not available for subdomain tenants")
);
res.redirect("/useradmin");
return;
}
// TBD describe logic around letsencrypt
const letsencrypt = getState().getConfig("letsencrypt", false);
const has_custom =
getState().getConfig("custom_ssl_certificate", false) &&
getState().getConfig("custom_ssl_private_key", false);
const show_warning =
!hostname_matches_baseurl(req, getBaseDomain()) &&
is_hsts_tld(getBaseDomain());
send_users_page({
res,
req,
active_sub: "SSL",
contents: {
above: [
...(letsencrypt && has_custom
? [
{
type: "card",
contents: p(
req.__(
"You have enabled both Let's Encrypt certificates and custom SSL certificates. Let's Encrypt takes priority and the custom certificates will be ignored."
)
),
},
]
: []),
{
type: "card",
title: req.__(
"HTTPS encryption with Let's Encrypt SSL certificate"
),
contents: [
p(
req.__(
`Saltcorn can automatically obtain an SSL certificate from <a href="https://letsencrypt.org/">Let's Encrypt</a> for single domains`
)
),
h5(
req.__("Currently: "),
letsencrypt
? span({ class: "badge badge-primary" }, req.__("Enabled"))
: span({ class: "badge badge-secondary" }, req.__("Disabled"))
),
letsencrypt
? post_btn(
"/config/delete/letsencrypt",
req.__("Disable LetsEncrypt HTTPS"),
req.csrfToken(),
{ btnClass: "btn-danger", req }
)
: post_btn(
"/admin/enable-letsencrypt",
req.__("Enable LetsEncrypt HTTPS"),
req.csrfToken(),
{ confirm: true, req }
),
!letsencrypt &&
show_warning &&
!has_custom &&
div(
{ class: "mt-3 alert alert-danger" },
p(
req.__(
"The address you are using to reach Saltcorn does not match the Base URL."
)
),
p(
req.__(
"The DNS A records (for * and @, or a subdomain) should point to this server's IP address before enabling LetsEncrypt"
)
)
),
],
},
{
type: "card",
title: req.__("HTTPS encryption with custom SSL certificate"),
contents: [
p(
req.__(
`Or use custom SSL certificates, including wildcard certificates for multitenant applications`
)
),
h5(
req.__("Currently: "),
has_custom
? span({ class: "badge badge-primary" }, req.__("Enabled"))
: span({ class: "badge badge-secondary" }, req.__("Disabled"))
),
// TBD change to button
link("/useradmin/ssl/custom", req.__("Edit custom SSL certificates")),
],
},
],
},
});
})
);
const ssl_form = (req) =>
config_fields_form({
req,
field_names: ["custom_ssl_certificate", "custom_ssl_private_key"],
action: "/useradmin/ssl/custom",
});
router.get(
"/ssl/custom",
setTenant,
isAdmin,
error_catcher(async (req, res) => {
const form = await ssl_form(req);
send_users_page({
res,
req,
active_sub: "Settings",
contents: {
type: "card",
title: req.__("Authentication settings"),
sub2_page: req.__("Custom SSL certificates"),
contents: [renderForm(form, req.csrfToken())],
},
});
})
);
router.post(
"/ssl/custom",
setTenant,
isAdmin,
error_catcher(async (req, res) => {
const form = await ssl_form(req);
form.validate(req.body);
if (form.hasErrors) {
send_users_page({
res,
req,
active_sub: "Settings",
contents: {
type: "card",
title: req.__("Authentication settings"),
sub2_page: req.__("Custom SSL certificates"),
contents: [renderForm(form, req.csrfToken())],
},
});
} else {
await save_config_from_form(form);
req.flash(
"success",
req.__("Custom SSL enabled. Restart for changes to take effect.") +
" " +
a({ href: "/admin/system" }, req.__("Restart here"))
);
res.redirect("/useradmin/ssl");
}
})
);
router.get(
"/:id",
setTenant,
isAdmin,
error_catcher(async (req, res) => {
const { id } = req.params;
const user = await User.findOne({ id });
const form = await userForm(req, user);
send_users_page({
res,
req,
active_sub: "Users",
sub2_page: user.email,
contents: {
above: [
{
type: "card",
title: req.__("Edit user %s", user.email),
contents: renderForm(form, req.csrfToken()),
},
{
type: "card",
title: req.__("API token"),
contents: [
// api token for user
div(
user.api_token
? span({ class: "mr-1" }, req.__("API token for this user: ")) +
code(user.api_token)
: req.__("No API token issued")
),
// button for reset or generate api token
div(
{ class: "mt-4" },
post_btn(
`/useradmin/gen-api-token/${user.id}`,
user.api_token ? req.__("Reset") : req.__("Generate"),
req.csrfToken()
)
),
// button for remove api token
user.api_token && div(
{ class: "mt-4" },
post_btn(
`/useradmin/remove-api-token/${user.id}`,
// TBD localization
user.api_token ? req.__("Remove") : req.__("Generate"),
req.csrfToken(),
{ req: req, confirm: true }
)
),
],
},
],
},
});
})
);
/**
* Save user data
*/
router.post(
"/save",
setTenant,
isAdmin,
error_catcher(async (req, res) => {
let {
email,
password,
role_id,
id,
rnd_password,
send_pwreset_email,
_csrf,
...rest
} = req.body;
if (id) {
try {
await db.update("users", { email, role_id, ...rest }, id);
req.flash("success", req.__(`User %s saved`, email));
} catch (e) {
req.flash("error", req.__(`Error editing user: %s`, e.message));
}
} else {
if (rnd_password) password = User.generate_password();
const u = await User.create({
email,
password,
role_id: +role_id,
...rest,
});
const pwflash =
rnd_password && !send_pwreset_email
? req.__(` with password %s`, code(password))
: "";
if (u.error) req.flash("error", u.error);
else req.flash("success", req.__(`User %s created`, email) + pwflash);
if (rnd_password && send_pwreset_email) await send_reset_email(u, req);
}
res.redirect(`/useradmin`);
})
);
/**
* Reset password for yser
*/
router.post(
"/reset-password/:id",
setTenant,
isAdmin,
error_catcher(async (req, res) => {
const { id } = req.params;
const u = await User.findOne({ id });
await send_reset_email(u, req);
req.flash("success", req.__(`Reset password link sent to %s`, u.email));
res.redirect(`/useradmin`);
})
);
/**
* Send verification email for user
*/
router.post(
"/send-verification/:id",
setTenant,
isAdmin,
error_catcher(async (req, res) => {
const { id } = req.params;
const u = await User.findOne({ id });
const result = await send_verification_email(u);
console.log(result);
if (result.error) req.flash("danger", result.error);
else
req.flash(
"success",
req.__(`Email verification link sent to %s`, u.email)
);
res.redirect(`/useradmin`);
})
);
/**
* Get new api token
*/
router.post(
"/gen-api-token/:id",
setTenant,
isAdmin,
error_catcher(async (req, res) => {
const { id } = req.params;
const u = await User.findOne({ id });
await u.getNewAPIToken();
req.flash("success", req.__(`New API token generated`));
res.redirect(`/useradmin/${u.id}`);
})
);
/**
* Remove api token
*/
router.post(
"/remove-api-token/:id",
setTenant,
isAdmin,
error_catcher(async (req, res) => {
const { id } = req.params;
const u = await User.findOne({ id });
await u.removeAPIToken();
req.flash("success", req.__(`API token removed`));
res.redirect(`/useradmin/${u.id}`);
})
);
/**
* Set random password
*/
router.post(
"/set-random-password/:id",
setTenant,
isAdmin,
error_catcher(async (req, res) => {
const { id } = req.params;
const u = await User.findOne({ id });
const newpw = User.generate_password();
await u.changePasswordTo(newpw);
await u.destroy_sessions();
req.flash(
"success",
req.__(`Changed password for user %s to %s`, u.email, newpw)
);
res.redirect(`/useradmin`);
})
);
router.post(
"/disable/:id",
setTenant,
isAdmin,
error_catcher(async (req, res) => {
const { id } = req.params;
const u = await User.findOne({ id });
await u.update({ disabled: true });
await u.destroy_sessions();
req.flash("success", req.__(`Disabled user %s`, u.email));
res.redirect(`/useradmin`);
})
);
router.post(
"/enable/:id",
setTenant,
isAdmin,
error_catcher(async (req, res) => {
const { id } = req.params;
const u = await User.findOne({ id });
await u.update({ disabled: false });
req.flash("success", req.__(`Enabled user %s`, u.email));
res.redirect(`/useradmin`);
})
);
router.post(
"/delete/:id",
setTenant,
isAdmin,
error_catcher(async (req, res) => {
const { id } = req.params;
const u = await User.findOne({ id });
await u.delete();
req.flash("success", req.__(`User %s deleted`, u.email));
res.redirect(`/useradmin`);
})
);