@saltcorn/server 0.6.3-beta.0 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app.js CHANGED
@@ -38,7 +38,7 @@ const { h1 } = require("@saltcorn/markup/tags");
38
38
  const is = require("contractis/is");
39
39
  const Trigger = require("@saltcorn/data/models/trigger");
40
40
  const s3storage = require("./s3storage");
41
-
41
+ const TotpStrategy = require("passport-totp").Strategy;
42
42
  const locales = Object.keys(available_languages);
43
43
  // i18n configuration
44
44
  const i18n = new I18n({
@@ -148,7 +148,9 @@ const getApp = async (opts = {}) => {
148
148
  req.flash("danger", req.__("Incorrect user or password"))
149
149
  );
150
150
  const mu = await User.authenticate(userobj);
151
- if (mu) return done(null, mu.session_object);
151
+ if (mu && mu._attributes.totp_enabled)
152
+ return done(null, { pending_user: mu.session_object });
153
+ else if (mu) return done(null, mu.session_object);
152
154
  else {
153
155
  const { password, ...nopw } = userobj;
154
156
  Trigger.emitEvent("LoginFailed", null, null, nopw);
@@ -188,6 +190,14 @@ const getApp = async (opts = {}) => {
188
190
  }
189
191
  })
190
192
  );
193
+ passport.use(
194
+ new TotpStrategy(function (user, done) {
195
+ // setup function, supply key and period to done callback
196
+ User.findOne({ id: user.pending_user.id }).then((u) => {
197
+ return done(null, u._attributes.totp_key, 30);
198
+ });
199
+ })
200
+ );
191
201
  passport.serializeUser(function (user, done) {
192
202
  done(null, user);
193
203
  });
package/auth/roleadmin.js CHANGED
@@ -85,6 +85,28 @@ const editRoleLayoutForm = (role, layouts, layout_by_role, req) =>
85
85
  )
86
86
  );
87
87
 
88
+ /**
89
+ * @param {Role} role
90
+ * @param {Layout[]} layouts
91
+ * @param {*} layout_by_role
92
+ * @param {object} req
93
+ * @returns {Form}
94
+ */
95
+ const editRole2FAPolicyForm = (role, twofa_policy_by_role, req) =>
96
+ form(
97
+ {
98
+ action: `/roleadmin/setrole2fapolicy/${role.id}`,
99
+ method: "post",
100
+ },
101
+ csrfField(req),
102
+ select(
103
+ { name: "policy", onchange: "form.submit()" },
104
+ ["Optional", "Disabled", "Mandatory"].map((p) =>
105
+ option({ selected: twofa_policy_by_role[role.id] === p }, p)
106
+ )
107
+ )
108
+ );
109
+
88
110
  /**
89
111
  * @param {object} req
90
112
  * @returns {Form}
@@ -125,6 +147,7 @@ router.get(
125
147
  (l) => l !== "emergency"
126
148
  );
127
149
  const layout_by_role = getState().getConfig("layout_by_role");
150
+ const twofa_policy_by_role = getState().getConfig("twofa_policy_by_role");
128
151
  send_users_page({
129
152
  res,
130
153
  req,
@@ -142,6 +165,13 @@ router.get(
142
165
  key: (role) =>
143
166
  editRoleLayoutForm(role, layouts, layout_by_role, req),
144
167
  },
168
+ {
169
+ label: req.__("2FA policy"),
170
+ key: (role) =>
171
+ role.id === 10
172
+ ? ""
173
+ : editRole2FAPolicyForm(role, twofa_policy_by_role, req),
174
+ },
145
175
  {
146
176
  label: req.__("Delete"),
147
177
  key: (r) =>
@@ -240,6 +270,26 @@ router.post(
240
270
  })
241
271
  );
242
272
 
273
+ /**
274
+ * @name post/setrolelayout/:id
275
+ * @function
276
+ * @memberof module:auth/roleadmin~roleadminRouter
277
+ */
278
+ router.post(
279
+ "/setrole2fapolicy/:id",
280
+ isAdmin,
281
+ error_catcher(async (req, res) => {
282
+ const { id } = req.params;
283
+ const twofa_policy_by_role = getState().getConfigCopy(
284
+ "twofa_policy_by_role"
285
+ );
286
+ twofa_policy_by_role[+id] = req.body.policy;
287
+ await getState().setConfig("twofa_policy_by_role", twofa_policy_by_role);
288
+ req.flash("success", req.__(`Saved 2FA policy for role`));
289
+
290
+ res.redirect(`/roleadmin`);
291
+ })
292
+ );
243
293
  const unDeletableRoles = [1, 8, 10];
244
294
  /**
245
295
  * @name post/delete/:id
package/auth/routes.js CHANGED
@@ -24,12 +24,14 @@ const { renderForm, post_btn } = require("@saltcorn/markup");
24
24
  const passport = require("passport");
25
25
  const {
26
26
  a,
27
+ img,
27
28
  text,
28
29
  table,
29
30
  tbody,
30
31
  th,
31
32
  td,
32
33
  tr,
34
+ h4,
33
35
  form,
34
36
  select,
35
37
  option,
@@ -37,6 +39,8 @@ const {
37
39
  i,
38
40
  div,
39
41
  code,
42
+ pre,
43
+ p,
40
44
  } = require("@saltcorn/markup/tags");
41
45
  const {
42
46
  available_languages,
@@ -52,7 +56,9 @@ const { restore_backup } = require("../markup/admin.js");
52
56
  const { restore } = require("@saltcorn/data/models/backup");
53
57
  const load_plugins = require("../load_plugins");
54
58
  const fs = require("fs");
55
-
59
+ const base32 = require("thirty-two");
60
+ const qrcode = require("qrcode");
61
+ const totp = require("notp").totp;
56
62
  /**
57
63
  * @type {object}
58
64
  * @const
@@ -599,6 +605,8 @@ const signup_login_with_user = (u, req, res) =>
599
605
  if (!err) {
600
606
  Trigger.emitEvent("Login", null, u);
601
607
  if (getState().verifier) res.redirect("/auth/verification-flow");
608
+ else if (getState().get2FApolicy(u) === "Mandatory")
609
+ res.redirect("/auth/twofa/setup/totp");
602
610
  else res.redirect("/");
603
611
  } else {
604
612
  req.flash("danger", err);
@@ -920,6 +928,11 @@ router.post(
920
928
  error_catcher(async (req, res) => {
921
929
  ipLimiter.resetKey(req.ip);
922
930
  userLimiter.resetKey(userIdKey(req.body));
931
+ if (req.user.pending_user) {
932
+ res.redirect("/auth/twofa/login/totp");
933
+ return;
934
+ }
935
+
923
936
  if (req.session.cookie)
924
937
  if (req.body.remember) {
925
938
  const setDur = +getState().getConfig("cookie_duration_remember", 0);
@@ -932,7 +945,9 @@ router.post(
932
945
  }
933
946
  Trigger.emitEvent("Login", null, req.user);
934
947
  req.flash("success", req.__("Welcome, %s!", req.user.email));
935
- res.redirect("/");
948
+ if (getState().get2FApolicy(req.user) === "Mandatory") {
949
+ res.redirect("/auth/twofa/setup/totp");
950
+ } else res.redirect("/");
936
951
  })
937
952
  );
938
953
 
@@ -1095,6 +1110,9 @@ const userSettings = async ({ req, res, pwform, user }) => {
1095
1110
  }
1096
1111
  let apikeycard;
1097
1112
  const min_role_apikeygen = +getState().getConfig("min_role_apikeygen", 1);
1113
+ const twoFaPolicy = getState().get2FApolicy(user);
1114
+ const show2FAPolicy =
1115
+ twoFaPolicy !== "Disabled" || user._attributes.totp_enabled;
1098
1116
  if (user.role_id <= min_role_apikeygen)
1099
1117
  apikeycard = {
1100
1118
  type: "card",
@@ -1163,6 +1181,40 @@ const userSettings = async ({ req, res, pwform, user }) => {
1163
1181
  title: req.__("Change password"),
1164
1182
  contents: renderForm(pwform, req.csrfToken()),
1165
1183
  },
1184
+ ...(show2FAPolicy
1185
+ ? [
1186
+ {
1187
+ type: "card",
1188
+ title: req.__("Two-factor authentication"),
1189
+ contents: [
1190
+ div(
1191
+ user._attributes.totp_enabled
1192
+ ? req.__("Two-factor authentication is enabled")
1193
+ : req.__("Two-factor authentication is disabled")
1194
+ ),
1195
+ div(
1196
+ user._attributes.totp_enabled
1197
+ ? post_btn(
1198
+ "/auth/twofa/disable/totp",
1199
+ "Disable",
1200
+ req.csrfToken(),
1201
+ {
1202
+ btnClass: "btn-danger mt-2",
1203
+ req,
1204
+ }
1205
+ )
1206
+ : a(
1207
+ {
1208
+ href: "/auth/twofa/setup/totp",
1209
+ class: "btn btn-primary mt-2",
1210
+ },
1211
+ "Enable"
1212
+ )
1213
+ ),
1214
+ ],
1215
+ },
1216
+ ]
1217
+ : []),
1166
1218
  ...(apikeycard ? [apikeycard] : []),
1167
1219
  ],
1168
1220
  };
@@ -1253,6 +1305,12 @@ router.get(
1253
1305
  loggedIn,
1254
1306
  error_catcher(async (req, res) => {
1255
1307
  const user = await User.findOne({ id: req.user.id });
1308
+ if (!user) {
1309
+ req.logout();
1310
+ req.flash("danger", req.__("Must be logged in first"));
1311
+ res.redirect("/auth/login");
1312
+ return;
1313
+ }
1256
1314
  res.sendWrap(
1257
1315
  req.__("User settings"),
1258
1316
  await userSettings({ req, res, pwform: changPwForm(req), user })
@@ -1436,3 +1494,171 @@ router.all(
1436
1494
  res.redirect(wfres.redirect || "/");
1437
1495
  })
1438
1496
  );
1497
+
1498
+ /**
1499
+ * @name get/settings
1500
+ * @function
1501
+ * @memberof module:auth/routes~routesRouter
1502
+ */
1503
+ router.get(
1504
+ "/twofa/setup/totp",
1505
+ loggedIn,
1506
+ error_catcher(async (req, res) => {
1507
+ const user = await User.findOne({ id: req.user.id });
1508
+ let key;
1509
+ if (user._attributes.totp_key) key = user._attributes.totp_key;
1510
+ else {
1511
+ key = randomKey(10);
1512
+ user._attributes.totp_key = key;
1513
+ await user.update({ _attributes: user._attributes });
1514
+ }
1515
+
1516
+ const encodedKey = base32.encode(key);
1517
+
1518
+ // generate QR code for scanning into Google Authenticator
1519
+ // reference: https://code.google.com/p/google-authenticator/wiki/KeyUriFormat
1520
+ const site_name = getState().getConfig("site_name");
1521
+ const otpUrl = `otpauth://totp/${
1522
+ user.email
1523
+ }?secret=${encodedKey}&period=30&issuer=${encodeURIComponent(site_name)}`;
1524
+ const image = await qrcode.toDataURL(otpUrl);
1525
+ res.sendWrap(req.__("Setup two-factor authentication"), {
1526
+ type: "card",
1527
+ title: req.__(
1528
+ "Setup two-factor authentication with Time-based One-Time Password (TOTP)"
1529
+ ),
1530
+ contents: [
1531
+ h4(req.__("1. Scan this QR code in your Authenticator app")),
1532
+ img({ src: image }),
1533
+ p("Or enter this code:"),
1534
+ code(pre(encodedKey.toString())),
1535
+ h4(
1536
+ req.__(
1537
+ "2. Enter the six-digit code generated in your Authenticator app"
1538
+ )
1539
+ ),
1540
+ renderForm(totpForm(req), req.csrfToken()),
1541
+ ],
1542
+ });
1543
+ })
1544
+ );
1545
+
1546
+ router.post(
1547
+ "/twofa/setup/totp",
1548
+ loggedIn,
1549
+ error_catcher(async (req, res) => {
1550
+ const user = await User.findOne({ id: req.user.id });
1551
+
1552
+ if (!user._attributes.totp_key) {
1553
+ //key not set
1554
+ req.flash("danger", req.__("2FA TOTP Key not set"));
1555
+ res.redirect("/auth/twofa/setup/totp");
1556
+ return;
1557
+ }
1558
+
1559
+ const form = totpForm(req);
1560
+ form.validate(req.body);
1561
+ if (form.hasErrors) {
1562
+ req.flash("danger", req.__("Error processing form"));
1563
+ res.redirect("/auth/twofa/setup/totp");
1564
+ return;
1565
+ }
1566
+ const code = `${form.values.totpCode}`;
1567
+ const rv = totp.verify(code, user._attributes.totp_key, {
1568
+ time: 30,
1569
+ });
1570
+ if (!rv) {
1571
+ req.flash("danger", req.__("Could not verify code"));
1572
+ res.redirect("/auth/twofa/setup/totp");
1573
+ return;
1574
+ }
1575
+ user._attributes.totp_enabled = true;
1576
+ await user.update({ _attributes: user._attributes });
1577
+ req.flash(
1578
+ "success",
1579
+ req.__(
1580
+ "Two-factor authentication with Time-based One-Time Password enabled"
1581
+ )
1582
+ );
1583
+
1584
+ res.redirect("/auth/settings");
1585
+ })
1586
+ );
1587
+
1588
+ router.post(
1589
+ "/twofa/disable/totp",
1590
+ loggedIn,
1591
+ error_catcher(async (req, res) => {
1592
+ const user = await User.findOne({ id: req.user.id });
1593
+ user._attributes.totp_enabled = false;
1594
+ delete user._attributes.totp_key;
1595
+ await user.update({ _attributes: user._attributes });
1596
+ req.flash(
1597
+ "success",
1598
+ req.__(
1599
+ "Two-factor authentication with Time-based One-Time Password disabled"
1600
+ )
1601
+ );
1602
+ res.redirect("/auth/settings");
1603
+ })
1604
+ );
1605
+ const totpForm = (req) =>
1606
+ new Form({
1607
+ action: "/auth/twofa/setup/totp",
1608
+ fields: [
1609
+ {
1610
+ name: "totpCode",
1611
+ label: req.__("Code"),
1612
+ type: "Integer",
1613
+ required: true,
1614
+ },
1615
+ ],
1616
+ });
1617
+
1618
+ const randomKey = function (len) {
1619
+ function getRandomInt(min, max) {
1620
+ return Math.floor(Math.random() * (max - min + 1)) + min;
1621
+ }
1622
+ var buf = [],
1623
+ chars = "abcdefghijklmnopqrstuvwxyz0123456789",
1624
+ charlen = chars.length;
1625
+
1626
+ for (var i = 0; i < len; ++i) {
1627
+ buf.push(chars[getRandomInt(0, charlen - 1)]);
1628
+ }
1629
+
1630
+ return buf.join("");
1631
+ };
1632
+
1633
+ router.get(
1634
+ "/twofa/login/totp",
1635
+ error_catcher(async (req, res) => {
1636
+ const form = new Form({
1637
+ action: "/auth/twofa/login/totp",
1638
+ submitLabel: "Verify",
1639
+ fields: [
1640
+ {
1641
+ name: "code",
1642
+ label: req.__("Code"),
1643
+ type: "Integer",
1644
+ required: true,
1645
+ },
1646
+ ],
1647
+ });
1648
+ res.sendAuthWrap(req.__(`Two-factor authentication`), form, {});
1649
+ })
1650
+ );
1651
+
1652
+ router.post(
1653
+ "/twofa/login/totp",
1654
+ passport.authenticate("totp", {
1655
+ failureRedirect: "/auth/twofa/login/totp",
1656
+ failureFlash: true,
1657
+ }),
1658
+ error_catcher(async (req, res) => {
1659
+ const user = await User.findOne({ id: req.user.pending_user.id });
1660
+ user.relogin(req);
1661
+ Trigger.emitEvent("Login", null, user);
1662
+ res.redirect("/");
1663
+ })
1664
+ );
package/locales/en.json CHANGED
@@ -845,5 +845,19 @@
845
845
  "User should have this role or higher to generate API keys in their user settings": "User should have this role or higher to generate API keys in their user settings",
846
846
  "API token removed": "API token removed",
847
847
  "Row inclusion formula": "Row inclusion formula",
848
- "Only include rows where this formula is true": "Only include rows where this formula is true"
848
+ "Only include rows where this formula is true": "Only include rows where this formula is true",
849
+ "Slug": "Slug",
850
+ "Field that can be used for a prettier URL structure": "Field that can be used for a prettier URL structure",
851
+ "Setup two-factor authentication": "Setup two-factor authentication",
852
+ "Setup two-factor authentication with Time-based One-Time Password (TOTP)": "Setup two-factor authentication with Time-based One-Time Password (TOTP)",
853
+ "1. Scan this QR code in your Authenticator app": "1. Scan this QR code in your Authenticator app",
854
+ "2. Enter the six-digit code generated in your Authenticator app": "2. Enter the six-digit code generated in your Authenticator app",
855
+ "Code": "Code",
856
+ "Two-factor authentication with Time-based One-Time Password enabled": "Two-factor authentication with Time-based One-Time Password enabled",
857
+ "Two-factor authentication": "Two-factor authentication",
858
+ "Two-factor authentication is enabled": "Two-factor authentication is enabled",
859
+ "Two-factor authentication with Time-based One-Time Password disabled": "Two-factor authentication with Time-based One-Time Password disabled",
860
+ "Two-factor authentication is disabled": "Two-factor authentication is disabled",
861
+ "Auto save": "Auto save",
862
+ "Save any changes immediately": "Save any changes immediately"
849
863
  }
package/package.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "@saltcorn/server",
3
- "version": "0.6.3-beta.0",
3
+ "version": "0.6.3",
4
4
  "description": "Server app for Saltcorn, open-source no-code platform",
5
5
  "homepage": "https://saltcorn.com",
6
6
  "main": "index.js",
7
7
  "license": "MIT",
8
8
  "dependencies": {
9
- "@saltcorn/base-plugin": "0.6.3-beta.0",
10
- "@saltcorn/builder": "0.6.3-beta.0",
11
- "@saltcorn/data": "0.6.3-beta.0",
9
+ "@saltcorn/base-plugin": "0.6.3",
10
+ "@saltcorn/builder": "0.6.3",
11
+ "@saltcorn/data": "0.6.3",
12
12
  "greenlock-express": "^4.0.3",
13
- "@saltcorn/markup": "0.6.3-beta.0",
14
- "@saltcorn/sbadmin2": "0.6.3-beta.0",
13
+ "@saltcorn/markup": "0.6.3",
14
+ "@saltcorn/sbadmin2": "0.6.3",
15
15
  "@socket.io/cluster-adapter": "^0.1.0",
16
16
  "@socket.io/sticky": "^1.0.1",
17
17
  "connect-flash": "^0.1.1",
@@ -34,13 +34,17 @@
34
34
  "moment": "^2.27.0",
35
35
  "node-fetch": "2.6.2",
36
36
  "node-watch": "^0.7.2",
37
+ "notp": "2.0.3",
37
38
  "passport": "^0.4.1",
38
39
  "passport-custom": "^1.1.1",
39
40
  "passport-http-bearer": "^1.0.1",
41
+ "passport-totp": "0.0.2",
40
42
  "pg": "^8.2.1",
41
43
  "pluralize": "^8.0.0",
44
+ "qrcode": "1.5.0",
42
45
  "socket.io": "4.2.0",
43
46
  "tmp-promise": "^3.0.2",
47
+ "thirty-two": "1.0.2",
44
48
  "multer-s3": "^2.10.0",
45
49
  "multer": "^1.4.3",
46
50
  "aws-sdk": "^2.1037.0",
@@ -75,6 +79,7 @@
75
79
  "@saltcorn/sqlite/(.*)": "@saltcorn/sqlite/dist/$1",
76
80
  "@saltcorn/db-common/(.*)": "@saltcorn/db-common/dist/$1",
77
81
  "@saltcorn/data/(.*)": "@saltcorn/data/dist/$1",
82
+ "@saltcorn/types/(.*)": "@saltcorn/types/dist/$1",
78
83
  "@saltcorn/markup$": "@saltcorn/markup/dist",
79
84
  "@saltcorn/markup/(.*)": "@saltcorn/markup/dist/$1"
80
85
  }
@@ -179,7 +179,9 @@ DateField.prototype = new jsGrid.Field({
179
179
  setTimeout(function () {
180
180
  flatpickr(insertPicker, {
181
181
  enableTime: true,
182
- dateFormat: "Y-m-d H:i",
182
+ dateFormat: "Z",
183
+ altInput: true,
184
+ altFormat: "Y-m-d h:i K",
183
185
  });
184
186
  });
185
187
  return insertPicker;
@@ -192,7 +194,9 @@ DateField.prototype = new jsGrid.Field({
192
194
  setTimeout(function () {
193
195
  flatpickr(editPicker, {
194
196
  enableTime: true,
195
- dateFormat: "Y-m-d H:i",
197
+ dateFormat: "Z",
198
+ altInput: true,
199
+ altFormat: "Y-m-d h:i K",
196
200
  });
197
201
  });
198
202
  return editPicker;
@@ -30,9 +30,14 @@ function add_repeater(nm) {
30
30
  function apply_showif() {
31
31
  $("[data-show-if]").each(function (ix, element) {
32
32
  var e = $(element);
33
- var to_show = new Function("e", "return " + e.attr("data-show-if"));
33
+ var to_show = new Function(
34
+ "e",
35
+ "return " + decodeURIComponent(e.attr("data-show-if"))
36
+ );
34
37
  if (to_show(e))
35
- e.show().find("input, textarea, button, select").prop("disabled", false);
38
+ e.show()
39
+ .find("input, textarea, button, select")
40
+ .prop("disabled", e.attr("data-disabled") || false);
36
41
  else
37
42
  e.hide().find("input, textarea, button, select").prop("disabled", true);
38
43
  });
@@ -508,6 +513,33 @@ function ajax_modal(url, opts = {}) {
508
513
  },
509
514
  });
510
515
  }
516
+
517
+ function saveAndContinue(e) {
518
+ var form = $(e).closest("form");
519
+ var url = form.attr("action");
520
+ var form_data = form.serialize();
521
+ $.ajax(url, {
522
+ type: "POST",
523
+ headers: {
524
+ "CSRF-Token": _sc_globalCsrf,
525
+ },
526
+ data: form_data,
527
+ success: function (res) {
528
+ if (res.id && form.find("input[name=id")) {
529
+ form.append(
530
+ `<input type="hidden" class="form-control " name="id" value="${res.id}">`
531
+ );
532
+ }
533
+ },
534
+ error: function (request) {
535
+ $("#page-inner-content").html(request.responseText);
536
+ initialize_page();
537
+ },
538
+ });
539
+
540
+ return false;
541
+ }
542
+
511
543
  function ajaxSubmitForm(e) {
512
544
  var form = $(e).closest("form");
513
545
  var url = form.attr("action");
@@ -120,6 +120,9 @@ const listenForChanges = (projectDirs, pluginDirs) => {
120
120
  (event, file) => {
121
121
  console.log("'%s' changed \n re-starting now", file);
122
122
  closeWatchers();
123
+ spawnSync("npm", ["run", "tsc"], {
124
+ stdio: "inherit",
125
+ });
123
126
  process.exit();
124
127
  }
125
128
  )
package/routes/admin.js CHANGED
@@ -769,6 +769,7 @@ router.post(
769
769
  for (const file of files) {
770
770
  await file.delete();
771
771
  }
772
+ if (db.reset_sequence) await db.reset_sequence("_sc_files");
772
773
  }
773
774
  if (form.values.plugins) {
774
775
  const ps = await Plugin.find();
package/routes/api.js CHANGED
@@ -65,11 +65,12 @@ const limitFields = (fields) => (r) => {
65
65
  * @returns {boolean}
66
66
  */
67
67
  function accessAllowedRead(req, user, table) {
68
- const role = req.isAuthenticated()
69
- ? req.user.role_id
70
- : user && user.role_id
71
- ? user.role_id
72
- : 10;
68
+ const role =
69
+ req.user && req.user.id
70
+ ? req.user.role_id
71
+ : user && user.role_id
72
+ ? user.role_id
73
+ : 10;
73
74
 
74
75
  return role <= table.min_role_read;
75
76
  }
@@ -82,11 +83,12 @@ function accessAllowedRead(req, user, table) {
82
83
  * @returns {boolean}
83
84
  */
84
85
  function accessAllowedWrite(req, user, table) {
85
- const role = req.isAuthenticated()
86
- ? req.user.role_id
87
- : user && user.role_id
88
- ? user.role_id
89
- : 10;
86
+ const role =
87
+ req.user && req.user.id
88
+ ? req.user.role_id
89
+ : user && user.role_id
90
+ ? user.role_id
91
+ : 10;
90
92
 
91
93
  return role <= table.min_role_write;
92
94
  }
@@ -98,11 +100,12 @@ function accessAllowedWrite(req, user, table) {
98
100
  * @returns {boolean}
99
101
  */
100
102
  function accessAllowed(req, user, trigger) {
101
- const role = req.isAuthenticated()
102
- ? req.user.role_id
103
- : user && user.role_id
104
- ? user.role_id
105
- : 10;
103
+ const role =
104
+ req.user && req.user.id
105
+ ? req.user.role_id
106
+ : user && user.role_id
107
+ ? user.role_id
108
+ : 10;
106
109
 
107
110
  return role <= trigger.min_role;
108
111
  }
package/routes/delete.js CHANGED
@@ -33,7 +33,7 @@ router.post(
33
33
  const { name, id } = req.params;
34
34
  const { redirect } = req.query;
35
35
  const table = await Table.findOne({ name });
36
- const role = req.isAuthenticated() ? req.user.role_id : 10;
36
+ const role = req.user && req.user.id ? req.user.role_id : 10;
37
37
  try {
38
38
  if (role <= table.min_role_write) await table.deleteRows({ id });
39
39
  else if (table.ownership_field_id && req.user) {
package/routes/edit.js CHANGED
@@ -37,7 +37,7 @@ router.post(
37
37
  const { name, id, field_name } = req.params;
38
38
  const { redirect } = req.query;
39
39
  const table = await Table.findOne({ name });
40
- const role = req.isAuthenticated() ? req.user.role_id : 10;
40
+ const role = req.user && req.user.id ? req.user.role_id : 10;
41
41
  if (role <= table.min_role_write) await table.toggleBool(+id, field_name);
42
42
  else
43
43
  req.flash(
package/routes/fields.js CHANGED
@@ -26,6 +26,7 @@ const { isAdmin, error_catcher } = require("./utils.js");
26
26
  const expressionBlurb = require("../markup/expression_blurb");
27
27
  const { readState } = require("@saltcorn/data/plugin-helper");
28
28
  const { wizardCardTitle } = require("../markup/forms.js");
29
+ const FieldRepeat = require("@saltcorn/data/models/fieldrepeat");
29
30
 
30
31
  /**
31
32
  * @type {object}
@@ -161,8 +162,9 @@ const translateAttributes = (attrs, req) =>
161
162
  * @returns {object}
162
163
  */
163
164
  const translateAttribute = (attr, req) => {
164
- const res = { ...attr };
165
+ let res = { ...attr };
165
166
  if (res.sublabel) res.sublabel = req.__(res.sublabel);
167
+ if (res.isRepeat) res = new FieldRepeat(res);
166
168
  return res;
167
169
  };
168
170
 
@@ -660,7 +662,15 @@ router.post(
660
662
  if (fieldName.includes(".")) {
661
663
  const [refNm, targetNm] = fieldName.split(".");
662
664
  const ref = fields.find((f) => f.name === refNm);
665
+ if (!ref) {
666
+ res.send("");
667
+ return;
668
+ }
663
669
  const reftable = await Table.findOne({ name: ref.reftable_name });
670
+ if (!reftable) {
671
+ res.send("");
672
+ return;
673
+ }
664
674
  const reffields = await reftable.getFields();
665
675
  field = reffields.find((f) => f.name === targetNm);
666
676
  row = await reftable.getRow({});
package/routes/files.js CHANGED
@@ -136,7 +136,7 @@ router.get(
136
136
  router.get(
137
137
  "/download/:id",
138
138
  error_catcher(async (req, res) => {
139
- const role = req.isAuthenticated() ? req.user.role_id : 10;
139
+ const role = req.user && req.user.id ? req.user.role_id : 10;
140
140
  const user_id = req.user && req.user.id;
141
141
  const { id } = req.params;
142
142
  const file = await File.findOne({ id });
@@ -160,7 +160,7 @@ router.get(
160
160
  router.get(
161
161
  "/serve/:id",
162
162
  error_catcher(async (req, res) => {
163
- const role = req.isAuthenticated() ? req.user.role_id : 10;
163
+ const role = req.user && req.user.id ? req.user.role_id : 10;
164
164
  const user_id = req.user && req.user.id;
165
165
  const { id } = req.params;
166
166
  let file;
@@ -243,7 +243,7 @@ router.post(
243
243
  error_catcher(async (req, res) => {
244
244
  let jsonResp = {};
245
245
  const min_role_upload = getState().getConfig("min_role_upload", 1);
246
- const role = req.isAuthenticated() ? req.user.role_id : 10;
246
+ const role = req.user && req.user.id ? req.user.role_id : 10;
247
247
  if (role > +min_role_upload) {
248
248
  if (!req.xhr) req.flash("warning", req.__("Not authorized"));
249
249
  else jsonResp = { error: "Not authorized" };
@@ -398,7 +398,7 @@ const welcome_page = async (req) => {
398
398
  * @returns {Promise<void>}
399
399
  */
400
400
  const no_views_logged_in = async (req, res) => {
401
- const role = req.isAuthenticated() ? req.user.role_id : 10;
401
+ const role = req.user && req.user.id ? req.user.role_id : 10;
402
402
  if (role > 1 || req.user.tenant !== db.getTenantSchema())
403
403
  res.sendWrap(req.__("Hello"), req.__("Welcome to Saltcorn!"));
404
404
  else {
@@ -455,25 +455,26 @@ const get_config_response = async (role_id, res, req) => {
455
455
  }
456
456
  };
457
457
 
458
- /**
459
- * Function assigned to 'module.exports'.
460
- * @param {object} req
461
- * @param {object} res
462
- * @returns {Promise<void>}
463
- */
464
- module.exports = async (req, res) => {
465
- const isAuth = req.isAuthenticated();
466
- const role_id = req.user ? req.user.role_id : 10;
467
- const cfgResp = await get_config_response(role_id, res, req);
468
- if (cfgResp) return;
458
+ module.exports =
459
+ /**
460
+ * Function assigned to 'module.exports'.
461
+ * @param {object} req
462
+ * @param {object} res
463
+ * @returns {Promise<void>}
464
+ */
465
+ async (req, res) => {
466
+ const isAuth = req.user && req.user.id;
467
+ const role_id = req.user ? req.user.role_id : 10;
468
+ const cfgResp = await get_config_response(role_id, res, req);
469
+ if (cfgResp) return;
469
470
 
470
- if (!isAuth) {
471
- const hasUsers = await User.nonEmpty();
472
- if (!hasUsers) {
473
- res.redirect("/auth/create_first_user");
474
- return;
475
- } else res.redirect("/auth/login");
476
- } else {
477
- await no_views_logged_in(req, res);
478
- }
479
- };
471
+ if (!isAuth) {
472
+ const hasUsers = await User.nonEmpty();
473
+ if (!hasUsers) {
474
+ res.redirect("/auth/create_first_user");
475
+ return;
476
+ } else res.redirect("/auth/login");
477
+ } else {
478
+ await no_views_logged_in(req, res);
479
+ }
480
+ };
package/routes/index.js CHANGED
@@ -36,7 +36,7 @@
36
36
  * @property {module:routes/utils} utils
37
37
  * @property {module:routes/view} view
38
38
  * @property {module:routes/viewedit} viewedit
39
- *
39
+ *
40
40
  * @category server
41
41
  * @subcategory routes
42
42
  */
@@ -71,38 +71,39 @@ const useradmin = require("../auth/admin");
71
71
  const roleadmin = require("../auth/roleadmin");
72
72
  const scapi = require("./scapi");
73
73
 
74
- /**
75
- * Function assigned to 'module.exports'
76
- * @returns {void}
77
- */
78
- module.exports = (app) => {
79
- app.use("/table", table);
80
- app.use("/field", field);
81
- app.use("/files", files);
82
- app.use("/list", list);
83
- app.use("/edit", edit);
84
- app.use("/config", config);
85
- app.use("/plugins", plugins);
86
- app.use("/packs", packs);
87
- app.use("/menu", menu);
88
- app.use("/view", view);
89
- app.use("/crashlog", crashlog);
90
- app.use("/events", events);
91
- app.use("/page", page);
92
- app.use("/settings", settings);
93
- app.use("/pageedit", pageedit);
94
- app.use("/actions", actions);
95
- app.use("/eventlog", eventlog);
96
- app.use("/library", library);
97
- app.use("/site-structure", infoarch);
98
- app.use("/search", search);
99
- app.use("/admin", admin);
100
- app.use("/tenant", tenant);
101
- app.use("/api", api);
102
- app.use("/viewedit", viewedit);
103
- app.use("/delete", del);
104
- app.use("/auth", auth);
105
- app.use("/useradmin", useradmin);
106
- app.use("/roleadmin", roleadmin);
107
- app.use("/scapi", scapi);
108
- };
74
+ module.exports =
75
+ /**
76
+ * Function assigned to 'module.exports'
77
+ * @returns {void}
78
+ */
79
+ (app) => {
80
+ app.use("/table", table);
81
+ app.use("/field", field);
82
+ app.use("/files", files);
83
+ app.use("/list", list);
84
+ app.use("/edit", edit);
85
+ app.use("/config", config);
86
+ app.use("/plugins", plugins);
87
+ app.use("/packs", packs);
88
+ app.use("/menu", menu);
89
+ app.use("/view", view);
90
+ app.use("/crashlog", crashlog);
91
+ app.use("/events", events);
92
+ app.use("/page", page);
93
+ app.use("/settings", settings);
94
+ app.use("/pageedit", pageedit);
95
+ app.use("/actions", actions);
96
+ app.use("/eventlog", eventlog);
97
+ app.use("/library", library);
98
+ app.use("/site-structure", infoarch);
99
+ app.use("/search", search);
100
+ app.use("/admin", admin);
101
+ app.use("/tenant", tenant);
102
+ app.use("/api", api);
103
+ app.use("/viewedit", viewedit);
104
+ app.use("/delete", del);
105
+ app.use("/auth", auth);
106
+ app.use("/useradmin", useradmin);
107
+ app.use("/roleadmin", roleadmin);
108
+ app.use("/scapi", scapi);
109
+ };
package/routes/list.js CHANGED
@@ -155,6 +155,10 @@ const typeToJsGridType = (t, field) => {
155
155
  jsgField.editing = false;
156
156
  jsgField.inserting = false;
157
157
  }
158
+ if (field.primary_key) {
159
+ jsgField.inserting = false;
160
+ jsgField.editing = false;
161
+ }
158
162
  return jsgField;
159
163
  };
160
164
 
package/routes/page.js CHANGED
@@ -37,7 +37,7 @@ router.get(
37
37
  error_catcher(async (req, res) => {
38
38
  const { pagename } = req.params;
39
39
 
40
- const role = req.isAuthenticated() ? req.user.role_id : 10;
40
+ const role = req.user && req.user.id ? req.user.role_id : 10;
41
41
  const db_page = await Page.findOne({ name: pagename });
42
42
  if (db_page && role <= db_page.min_role) {
43
43
  const contents = await db_page.run(req.query, { res, req });
@@ -73,7 +73,7 @@ router.post(
73
73
  "/:pagename/action/:rndid",
74
74
  error_catcher(async (req, res) => {
75
75
  const { pagename, rndid } = req.params;
76
- const role = req.isAuthenticated() ? req.user.role_id : 10;
76
+ const role = req.user && req.user.id ? req.user.role_id : 10;
77
77
  const db_page = await Page.findOne({ name: pagename });
78
78
  if (db_page && role <= db_page.min_role) {
79
79
  let col;
@@ -157,9 +157,12 @@ const pageBuilderData = async (req, context) => {
157
157
  const images = await File.find({ mime_super: "image" });
158
158
  const roles = await User.get_roles();
159
159
  const stateActions = getState().actions;
160
- const actions = Object.entries(stateActions)
161
- .filter(([k, v]) => !v.requireRow && !v.disableInBuilder)
162
- .map(([k, v]) => k);
160
+ const actions = [
161
+ "GoBack",
162
+ ...Object.entries(stateActions)
163
+ .filter(([k, v]) => !v.requireRow && !v.disableInBuilder)
164
+ .map(([k, v]) => k),
165
+ ];
163
166
  const triggers = await Trigger.find({
164
167
  when_trigger: { or: ["API call", "Never"] },
165
168
  });
package/routes/scapi.js CHANGED
@@ -44,11 +44,12 @@ module.exports = router;
44
44
  * @returns {boolean}
45
45
  */
46
46
  function accessAllowedRead(req, user) {
47
- const role = req.isAuthenticated()
48
- ? req.user.role_id
49
- : user && user.role_id
50
- ? user.role_id
51
- : 10;
47
+ const role =
48
+ req.user && req.user.id
49
+ ? req.user.role_id
50
+ : user && user.role_id
51
+ ? user.role_id
52
+ : 10;
52
53
 
53
54
  if (role === 1) return true;
54
55
  return false;
package/routes/utils.js CHANGED
@@ -49,7 +49,13 @@ function isAdmin(req, res, next) {
49
49
  next();
50
50
  } else {
51
51
  req.flash("danger", req.__("Must be admin"));
52
- res.redirect(req.user ? "/" : "/auth/login");
52
+ res.redirect(
53
+ req.user && req.user.pending_user
54
+ ? "/auth/twofa/login/totp"
55
+ : req.user
56
+ ? "/"
57
+ : "/auth/login"
58
+ );
53
59
  }
54
60
  }
55
61
 
package/routes/view.js CHANGED
@@ -37,27 +37,28 @@ module.exports = router;
37
37
  * @function
38
38
  */
39
39
  router.get(
40
- "/:viewname",
40
+ ["/:viewname", "/:viewname/*"],
41
41
  error_catcher(async (req, res) => {
42
42
  const { viewname } = req.params;
43
-
43
+ const query = { ...req.query };
44
44
  const view = await View.findOne({ name: viewname });
45
- const role = req.isAuthenticated() ? req.user.role_id : 10;
46
-
45
+ const role = req.user && req.user.id ? req.user.role_id : 10;
47
46
  if (!view) {
48
47
  req.flash("danger", req.__(`No such view: %s`, text(viewname)));
49
48
  res.redirect("/");
50
49
  return;
51
50
  }
51
+
52
+ view.rewrite_query_from_slug(query, req.params);
52
53
  if (
53
54
  role > view.min_role &&
54
- !(await view.authorise_get({ query: req.query, req, ...view }))
55
+ !(await view.authorise_get({ query, req, ...view }))
55
56
  ) {
56
57
  req.flash("danger", req.__("Not authorized"));
57
58
  res.redirect("/");
58
59
  return;
59
60
  }
60
- const contents = await view.run_possibly_on_page(req.query, req, res);
61
+ const contents = await view.run_possibly_on_page(query, req, res);
61
62
  const title = scan_for_page_title(contents, view.name);
62
63
  res.sendWrap(
63
64
  title,
@@ -120,7 +121,7 @@ router.post(
120
121
  "/:viewname/:route",
121
122
  error_catcher(async (req, res) => {
122
123
  const { viewname, route } = req.params;
123
- const role = req.isAuthenticated() ? req.user.role_id : 10;
124
+ const role = req.user && req.user.id ? req.user.role_id : 10;
124
125
 
125
126
  const view = await View.findOne({ name: viewname });
126
127
  if (!view) {
@@ -142,16 +143,21 @@ router.post(
142
143
  * @function
143
144
  */
144
145
  router.post(
145
- "/:viewname",
146
+ ["/:viewname", "/:viewname/*"],
146
147
  error_catcher(async (req, res) => {
147
148
  const { viewname } = req.params;
148
- const role = req.isAuthenticated() ? req.user.role_id : 10;
149
+ const role = req.user && req.user.id ? req.user.role_id : 10;
150
+ const query = { ...req.query };
149
151
 
150
152
  const view = await View.findOne({ name: viewname });
151
153
  if (!view) {
152
154
  req.flash("danger", req.__(`No such view: %s`, text(viewname)));
153
155
  res.redirect("/");
154
- } else if (
156
+ return;
157
+ }
158
+ view.rewrite_query_from_slug(query, req.params);
159
+
160
+ if (
155
161
  role > view.min_role &&
156
162
  !(await view.authorise_post({ body: req.body, req, ...view }))
157
163
  ) {
@@ -164,7 +170,7 @@ router.post(
164
170
  } does not supply a POST handler`
165
171
  );
166
172
  } else {
167
- await view.runPost(req.query, req.body, { res, req });
173
+ await view.runPost(query, req.body, { res, req });
168
174
  }
169
175
  })
170
176
  );
@@ -232,12 +232,13 @@ const mapObjectValues = (o, f) =>
232
232
  * @param {object} values
233
233
  * @returns {Form}
234
234
  */
235
- const viewForm = (req, tableOptions, roles, pages, values) => {
235
+ const viewForm = async (req, tableOptions, roles, pages, values) => {
236
236
  const isEdit =
237
237
  values && values.id && !getState().getConfig("development_mode", false);
238
238
  const hasTable = Object.entries(getState().viewtemplates)
239
239
  .filter(([k, v]) => !v.tableless)
240
240
  .map(([k, v]) => k);
241
+ const slugOptions = await Table.allSlugOptions();
241
242
  return new Form({
242
243
  action: "/viewedit/save",
243
244
  submitLabel: req.__("Configure") + " &raquo;",
@@ -302,6 +303,19 @@ const viewForm = (req, tableOptions, roles, pages, values) => {
302
303
  ...pages.map((p) => ({ value: p.name, label: p.name })),
303
304
  ],
304
305
  }),
306
+ new Field({
307
+ name: "slug",
308
+ label: req.__("Slug"),
309
+ sublabel: req.__("Field that can be used for a prettier URL structure"),
310
+ type: "String",
311
+ attributes: {
312
+ calcOptions: [
313
+ "table_name",
314
+ mapObjectValues(slugOptions, (lvs) => lvs.map((lv) => lv.label)),
315
+ ],
316
+ },
317
+ showIf: { viewtemplate: hasTable },
318
+ }),
305
319
  ...(isEdit
306
320
  ? [
307
321
  new Field({
@@ -342,10 +356,15 @@ router.get(
342
356
  (t) => t.id === viewrow.table_id || t.name === viewrow.exttable_name
343
357
  );
344
358
  viewrow.table_name = currentTable && currentTable.name;
359
+ if (viewrow.slug && currentTable) {
360
+ const slugOptions = await currentTable.slug_options();
361
+ const slug = slugOptions.find((so) => so.label === viewrow.slug.label);
362
+ if (slug) viewrow.slug = slug.label;
363
+ }
345
364
  const tableOptions = tables.map((t) => t.name);
346
365
  const roles = await User.get_roles();
347
366
  const pages = await Page.find();
348
- const form = viewForm(req, tableOptions, roles, pages, viewrow);
367
+ const form = await viewForm(req, tableOptions, roles, pages, viewrow);
349
368
  form.hidden("id");
350
369
  res.sendWrap(req.__(`Edit view`), {
351
370
  above: [
@@ -380,7 +399,7 @@ router.get(
380
399
  const tableOptions = tables.map((t) => t.name);
381
400
  const roles = await User.get_roles();
382
401
  const pages = await Page.find();
383
- const form = viewForm(req, tableOptions, roles, pages);
402
+ const form = await viewForm(req, tableOptions, roles, pages);
384
403
  if (req.query && req.query.table) {
385
404
  form.values.table_name = req.query.table;
386
405
  }
@@ -417,7 +436,7 @@ router.post(
417
436
  const tableOptions = tables.map((t) => t.name);
418
437
  const roles = await User.get_roles();
419
438
  const pages = await Page.find();
420
- const form = viewForm(req, tableOptions, roles, pages);
439
+ const form = await viewForm(req, tableOptions, roles, pages);
421
440
  const result = form.validate(req.body);
422
441
 
423
442
  const sendForm = (form) => {
@@ -458,11 +477,18 @@ router.post(
458
477
  const v = result.success;
459
478
  if (v.table_name) {
460
479
  const table = await Table.findOne({ name: v.table_name });
461
- if (table && table.id) v.table_id = table.id;
462
- else if (table && table.external) v.exttable_name = v.table_name;
480
+ if (table && table.id) {
481
+ v.table_id = table.id;
482
+ } else if (table && table.external) v.exttable_name = v.table_name;
463
483
  }
484
+ if (v.table_id) {
485
+ const table = await Table.findOne({ id: v.table_id });
486
+ const slugOptions = await table.slug_options();
487
+ const slug = slugOptions.find((so) => so.label === v.slug);
488
+ v.slug = slug || null;
489
+ }
490
+ const table = await Table.findOne({ name: v.table_name });
464
491
  delete v.table_name;
465
-
466
492
  if (req.body.id) {
467
493
  await View.update(v, +req.body.id);
468
494
  } else {
package/serve.js CHANGED
@@ -163,9 +163,6 @@ module.exports =
163
163
  ...appargs
164
164
  } = {}) => {
165
165
  if (dev && cluster.isMaster) {
166
- spawnSync("npm", ["run", "tsc"], {
167
- stdio: "inherit",
168
- });
169
166
  listenForChanges(getRelevantPackages(), await getPluginDirectories());
170
167
  }
171
168
  const useNCpus = process.env.SALTCORN_NWORKERS
@@ -137,3 +137,41 @@ describe("render view on page", () => {
137
137
  .expect(toNotInclude("Herman Melville"));
138
138
  });
139
139
  });
140
+
141
+ describe("render view with slug", () => {
142
+ it("should show with id slug in list", async () => {
143
+ const view = await View.findOne({ name: "authorshow" });
144
+ const table = await Table.findOne({ name: "books" });
145
+ const slugOpts = await table.slug_options();
146
+ const slugOpt = slugOpts.find((so) => so.label === "/:id");
147
+ expect(!!slugOpt).toBe(true);
148
+ View.update({ default_render_page: null, slug: slugOpt }, view.id);
149
+ const app = await getApp({ disableCsrf: true });
150
+ await request(app)
151
+ .get("/view/authorlist")
152
+ .expect(toInclude(`/view/authorshow/1`));
153
+ await request(app)
154
+ .get("/view/authorshow/1")
155
+ .expect(toInclude(`Herman Melville`));
156
+ });
157
+ it("should show with name slug in list", async () => {
158
+ const view = await View.findOne({ name: "authorshow" });
159
+ const table0 = await Table.findOne({ name: "books" });
160
+ const fields = await table0.getFields();
161
+ const field = fields.find((f) => f.name === "author");
162
+ await field.update({ is_unique: true });
163
+ const table = await Table.findOne({ name: "books" });
164
+
165
+ const slugOpts = await table.slug_options();
166
+ const slugOpt = slugOpts.find((so) => so.label === "/slugify-author");
167
+ expect(!!slugOpt).toBe(true);
168
+ View.update({ default_render_page: null, slug: slugOpt }, view.id);
169
+ const app = await getApp({ disableCsrf: true });
170
+ await request(app)
171
+ .get("/view/authorlist")
172
+ .expect(toInclude(`/view/authorshow/herman-melville`));
173
+ await request(app)
174
+ .get("/view/authorshow/herman-melville")
175
+ .expect(toInclude(`Herman Melville`));
176
+ });
177
+ });
package/wrapper.js CHANGED
@@ -45,7 +45,7 @@ const get_extra_menu = (role, state, req) => {
45
45
  };
46
46
 
47
47
  const get_menu = (req) => {
48
- const isAuth = req.isAuthenticated();
48
+ const isAuth = req.user && req.user.id;
49
49
  const state = getState();
50
50
  const role = (req.user || {}).role_id || 10;
51
51