@saltcorn/server 0.9.6-beta.9 → 0.9.7-rc.0
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 +9 -2
- package/auth/admin.js +51 -52
- package/auth/roleadmin.js +6 -2
- package/auth/routes.js +28 -10
- package/auth/testhelp.js +86 -0
- package/help/Field label.tmd +11 -0
- package/help/Field types.tmd +39 -0
- package/help/Inclusion Formula.tmd +38 -0
- package/help/Ownership field.tmd +76 -0
- package/help/Ownership formula.tmd +75 -0
- package/help/Table roles.tmd +20 -0
- package/help/User groups.tmd +35 -0
- package/help/User roles.tmd +30 -0
- package/load_plugins.js +28 -4
- package/locales/en.json +28 -1
- package/locales/it.json +3 -2
- package/markup/forms.js +5 -1
- package/package.json +9 -9
- package/public/log_viewer_utils.js +32 -0
- package/public/mermaid.min.js +705 -306
- package/public/saltcorn-builder.css +23 -0
- package/public/saltcorn-common.js +195 -71
- package/public/saltcorn.css +72 -0
- package/public/saltcorn.js +78 -0
- package/restart_watcher.js +1 -0
- package/routes/actions.js +27 -0
- package/routes/admin.js +180 -66
- package/routes/api.js +6 -0
- package/routes/common_lists.js +42 -32
- package/routes/fields.js +9 -1
- package/routes/homepage.js +2 -0
- package/routes/menu.js +69 -4
- package/routes/notifications.js +90 -10
- package/routes/pageedit.js +18 -13
- package/routes/plugins.js +5 -1
- package/routes/search.js +10 -4
- package/routes/tables.js +47 -27
- package/routes/tenant.js +4 -15
- package/routes/utils.js +20 -6
- package/routes/viewedit.js +11 -7
- package/serve.js +27 -5
- package/tests/edit.test.js +426 -0
- package/tests/fields.test.js +21 -0
- package/tests/filter.test.js +68 -0
- package/tests/page.test.js +2 -2
- package/tests/sync.test.js +59 -0
- package/wrapper.js +4 -1
package/app.js
CHANGED
|
@@ -50,6 +50,7 @@ const locales = Object.keys(available_languages);
|
|
|
50
50
|
const i18n = new I18n({
|
|
51
51
|
locales,
|
|
52
52
|
directory: path.join(__dirname, "locales"),
|
|
53
|
+
mustacheConfig: { disable: true },
|
|
53
54
|
});
|
|
54
55
|
// jwt config
|
|
55
56
|
const jwt_secret = db.connectObj.jwt_secret;
|
|
@@ -171,9 +172,13 @@ const getApp = async (opts = {}) => {
|
|
|
171
172
|
const tenants = await getAllTenants();
|
|
172
173
|
await init_multi_tenant(loadAllPlugins, opts.disableMigrate, tenants);
|
|
173
174
|
}
|
|
175
|
+
const pruneSessionInterval = +getState().getConfig(
|
|
176
|
+
"prune_session_interval",
|
|
177
|
+
900
|
|
178
|
+
);
|
|
174
179
|
//
|
|
175
180
|
// todo ability to configure session_secret Age
|
|
176
|
-
app.use(getSessionStore());
|
|
181
|
+
app.use(getSessionStore(pruneSessionInterval));
|
|
177
182
|
|
|
178
183
|
app.use(passport.initialize());
|
|
179
184
|
app.use(passport.authenticate(["jwt", "session"]));
|
|
@@ -297,7 +302,9 @@ const getApp = async (opts = {}) => {
|
|
|
297
302
|
if (
|
|
298
303
|
u &&
|
|
299
304
|
u.last_mobile_login &&
|
|
300
|
-
u.last_mobile_login
|
|
305
|
+
(typeof u.last_mobile_login === "string"
|
|
306
|
+
? new Date(u.last_mobile_login).valueOf()
|
|
307
|
+
: u.last_mobile_login) <= jwt_payload.iat
|
|
301
308
|
) {
|
|
302
309
|
return done(null, {
|
|
303
310
|
email: u.email,
|
package/auth/admin.js
CHANGED
|
@@ -99,6 +99,10 @@ const getUserFields = async (req) => {
|
|
|
99
99
|
input_type: "email",
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
|
+
if (f.name === "role_id") {
|
|
103
|
+
f.fieldview = "role_select";
|
|
104
|
+
await f.fill_fkey_options();
|
|
105
|
+
}
|
|
102
106
|
}
|
|
103
107
|
return userFields;
|
|
104
108
|
};
|
|
@@ -110,65 +114,59 @@ const getUserFields = async (req) => {
|
|
|
110
114
|
* @param {User} user
|
|
111
115
|
* @returns {Promise<Form>}
|
|
112
116
|
*/
|
|
113
|
-
const userForm =
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
117
|
+
const userForm = async (req, user) => {
|
|
118
|
+
const roleField = new Field({
|
|
119
|
+
label: req.__("Role"),
|
|
120
|
+
name: "role_id",
|
|
121
|
+
type: "Key",
|
|
122
|
+
reftable_name: "roles",
|
|
123
|
+
});
|
|
124
|
+
const roles = (await User.get_roles()).filter((r) => r.role !== "public");
|
|
125
|
+
roleField.options = roles.map((r) => ({ label: r.role, value: r.id }));
|
|
126
|
+
const can_reset = getState().getConfig("smtp_host", "") !== "";
|
|
127
|
+
const userFields = await getUserFields(req);
|
|
128
|
+
const form = new Form({
|
|
129
|
+
fields: userFields,
|
|
130
|
+
action: "/useradmin/save",
|
|
131
|
+
submitLabel: user ? req.__("Save") : req.__("Create"),
|
|
132
|
+
});
|
|
133
|
+
if (!user) {
|
|
134
|
+
form.fields.push(
|
|
135
|
+
new Field({
|
|
136
|
+
label: req.__("Set random password"),
|
|
137
|
+
name: "rnd_password",
|
|
138
|
+
type: "Bool",
|
|
139
|
+
default: true,
|
|
140
|
+
})
|
|
141
|
+
);
|
|
142
|
+
form.fields.push(
|
|
143
|
+
new Field({
|
|
144
|
+
label: req.__("Password"),
|
|
145
|
+
name: "password",
|
|
146
|
+
input_type: "password",
|
|
147
|
+
showIf: { rnd_password: false },
|
|
148
|
+
})
|
|
149
|
+
);
|
|
150
|
+
can_reset &&
|
|
135
151
|
form.fields.push(
|
|
136
152
|
new Field({
|
|
137
|
-
label: req.__("
|
|
138
|
-
name: "
|
|
153
|
+
label: req.__("Send password reset email"),
|
|
154
|
+
name: "send_pwreset_email",
|
|
139
155
|
type: "Bool",
|
|
140
156
|
default: true,
|
|
157
|
+
showIf: { rnd_password: true },
|
|
141
158
|
})
|
|
142
159
|
);
|
|
143
|
-
form.fields.push(
|
|
144
|
-
new Field({
|
|
145
|
-
label: req.__("Password"),
|
|
146
|
-
name: "password",
|
|
147
|
-
input_type: "password",
|
|
148
|
-
showIf: { rnd_password: false },
|
|
149
|
-
})
|
|
150
|
-
);
|
|
151
|
-
can_reset &&
|
|
152
|
-
form.fields.push(
|
|
153
|
-
new Field({
|
|
154
|
-
label: req.__("Send password reset email"),
|
|
155
|
-
name: "send_pwreset_email",
|
|
156
|
-
type: "Bool",
|
|
157
|
-
default: true,
|
|
158
|
-
showIf: { rnd_password: true },
|
|
159
|
-
})
|
|
160
|
-
);
|
|
161
|
-
}
|
|
162
|
-
if (user) {
|
|
163
|
-
form.hidden("id");
|
|
164
|
-
form.values = user;
|
|
165
|
-
delete form.values.password;
|
|
166
|
-
} else {
|
|
167
|
-
form.values.role_id = roles[roles.length - 1].id;
|
|
168
|
-
}
|
|
169
|
-
return form;
|
|
170
160
|
}
|
|
171
|
-
)
|
|
161
|
+
if (user) {
|
|
162
|
+
form.hidden("id");
|
|
163
|
+
form.values = user;
|
|
164
|
+
delete form.values.password;
|
|
165
|
+
} else {
|
|
166
|
+
form.values.role_id = roles[roles.length - 1].id;
|
|
167
|
+
}
|
|
168
|
+
return form;
|
|
169
|
+
};
|
|
172
170
|
|
|
173
171
|
/**
|
|
174
172
|
* Dropdown for User Info in left menu
|
|
@@ -390,6 +388,7 @@ const http_settings_form = async (req) =>
|
|
|
390
388
|
"cross_domain_iframe",
|
|
391
389
|
"body_limit",
|
|
392
390
|
"url_encoded_limit",
|
|
391
|
+
...(!db.isSQLite ? ["prune_session_interval"] : []),
|
|
393
392
|
],
|
|
394
393
|
action: "/useradmin/http",
|
|
395
394
|
submitLabel: req.__("Save"),
|
package/auth/roleadmin.js
CHANGED
|
@@ -145,7 +145,9 @@ router.get(
|
|
|
145
145
|
active_sub: "Roles",
|
|
146
146
|
contents: {
|
|
147
147
|
type: "card",
|
|
148
|
-
title:
|
|
148
|
+
title:
|
|
149
|
+
req.__("Roles") +
|
|
150
|
+
`<a href="javascript:ajax_modal('/admin/help/User%20roles?')"><i class="fas fa-question-circle ms-1"></i></a>`,
|
|
149
151
|
contents: [
|
|
150
152
|
mkTable(
|
|
151
153
|
[
|
|
@@ -198,7 +200,9 @@ router.get(
|
|
|
198
200
|
sub2_page: "New",
|
|
199
201
|
contents: {
|
|
200
202
|
type: "card",
|
|
201
|
-
title:
|
|
203
|
+
title:
|
|
204
|
+
req.__("Roles") +
|
|
205
|
+
`<a href="javascript:ajax_modal('/admin/help/User%20roles?')"><i class="fas fa-question-circle ms-1"></i></a>`,
|
|
202
206
|
contents: [renderForm(form, req.csrfToken())],
|
|
203
207
|
},
|
|
204
208
|
});
|
package/auth/routes.js
CHANGED
|
@@ -1103,15 +1103,20 @@ router.post(
|
|
|
1103
1103
|
res.redirect("/auth/twofa/login/totp");
|
|
1104
1104
|
return;
|
|
1105
1105
|
}
|
|
1106
|
+
let maxAge = null;
|
|
1106
1107
|
if (req.session.cookie)
|
|
1107
1108
|
if (req.body.remember) {
|
|
1108
|
-
const setDur = +getState().getConfig("cookie_duration_remember",
|
|
1109
|
-
if (setDur)
|
|
1110
|
-
|
|
1109
|
+
const setDur = +getState().getConfig("cookie_duration_remember", 720);
|
|
1110
|
+
if (setDur) {
|
|
1111
|
+
maxAge = setDur * 60 * 60 * 1000;
|
|
1112
|
+
req.session.cookie.maxAge = maxAge;
|
|
1113
|
+
} else req.session.cookie.expires = false;
|
|
1111
1114
|
} else {
|
|
1112
|
-
const setDur = +getState().getConfig("cookie_duration",
|
|
1113
|
-
if (setDur)
|
|
1114
|
-
|
|
1115
|
+
const setDur = +getState().getConfig("cookie_duration", 720);
|
|
1116
|
+
if (setDur) {
|
|
1117
|
+
maxAge = setDur * 60 * 60 * 1000;
|
|
1118
|
+
req.session.cookie.maxAge = maxAge;
|
|
1119
|
+
} else req.session.cookie.expires = false;
|
|
1115
1120
|
}
|
|
1116
1121
|
const session_id = getSessionId(req);
|
|
1117
1122
|
|
|
@@ -1119,7 +1124,7 @@ router.post(
|
|
|
1119
1124
|
session_id,
|
|
1120
1125
|
old_session_id: req.old_session_id,
|
|
1121
1126
|
});
|
|
1122
|
-
res?.cookie?.("loggedin", "true");
|
|
1127
|
+
res?.cookie?.("loggedin", "true", maxAge ? { maxAge } : undefined);
|
|
1123
1128
|
req.flash("success", req.__("Welcome, %s!", req.user.email));
|
|
1124
1129
|
if (req.smr) {
|
|
1125
1130
|
const dbUser = await User.findOne({ id: req.user.id });
|
|
@@ -1155,7 +1160,11 @@ router.get(
|
|
|
1155
1160
|
} else {
|
|
1156
1161
|
const auth = getState().auth_methods[method];
|
|
1157
1162
|
if (auth) {
|
|
1158
|
-
|
|
1163
|
+
const passportParams =
|
|
1164
|
+
typeof auth.parameters === "function"
|
|
1165
|
+
? auth.parameters(req)
|
|
1166
|
+
: auth.parameters;
|
|
1167
|
+
passport.authenticate(method, passportParams)(req, res, next);
|
|
1159
1168
|
} else {
|
|
1160
1169
|
req.flash(
|
|
1161
1170
|
"danger",
|
|
@@ -1189,7 +1198,11 @@ router.post(
|
|
|
1189
1198
|
const { method } = req.params;
|
|
1190
1199
|
const auth = getState().auth_methods[method];
|
|
1191
1200
|
if (auth) {
|
|
1192
|
-
|
|
1201
|
+
const passportParams =
|
|
1202
|
+
typeof auth.parameters === "function"
|
|
1203
|
+
? auth.parameters(req)
|
|
1204
|
+
: auth.parameters;
|
|
1205
|
+
passport.authenticate(method, passportParams)(
|
|
1193
1206
|
req,
|
|
1194
1207
|
res,
|
|
1195
1208
|
loginCallback(req, res)
|
|
@@ -1227,7 +1240,12 @@ const callbackFn = async (req, res, next) => {
|
|
|
1227
1240
|
const { method } = req.params;
|
|
1228
1241
|
const auth = getState().auth_methods[method];
|
|
1229
1242
|
if (auth) {
|
|
1230
|
-
|
|
1243
|
+
const passportParams =
|
|
1244
|
+
typeof auth.parameters === "function"
|
|
1245
|
+
? auth.parameters(req)
|
|
1246
|
+
: auth.parameters;
|
|
1247
|
+
passportParams.failureRedirect = "/auth/login";
|
|
1248
|
+
passport.authenticate(method, passportParams)(
|
|
1231
1249
|
req,
|
|
1232
1250
|
res,
|
|
1233
1251
|
loginCallback(req, res)
|
package/auth/testhelp.js
CHANGED
|
@@ -9,6 +9,8 @@ const app = require("../app");
|
|
|
9
9
|
const getApp = require("../app");
|
|
10
10
|
const fixtures = require("@saltcorn/data/db/fixtures");
|
|
11
11
|
const reset = require("@saltcorn/data/db/reset_schema");
|
|
12
|
+
const jsdom = require("jsdom");
|
|
13
|
+
const { JSDOM, ResourceLoader } = jsdom;
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
*
|
|
@@ -307,6 +309,89 @@ const notFound = (res) => {
|
|
|
307
309
|
}
|
|
308
310
|
};
|
|
309
311
|
|
|
312
|
+
const load_url_dom = async (url) => {
|
|
313
|
+
const app = await getApp({ disableCsrf: true });
|
|
314
|
+
class CustomResourceLoader extends ResourceLoader {
|
|
315
|
+
async fetch(url, options) {
|
|
316
|
+
const url1 = url.replace("http://localhost", "");
|
|
317
|
+
//console.log("fetching", url, url1);
|
|
318
|
+
const res = await request(app).get(url1);
|
|
319
|
+
|
|
320
|
+
return Buffer.from(res.text);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const reqres = await request(app).get(url);
|
|
324
|
+
//console.log("rr1", reqres.text);
|
|
325
|
+
const virtualConsole = new jsdom.VirtualConsole();
|
|
326
|
+
virtualConsole.sendTo(console);
|
|
327
|
+
const dom = new JSDOM(reqres.text, {
|
|
328
|
+
url: "http://localhost" + url,
|
|
329
|
+
runScripts: "dangerously",
|
|
330
|
+
resources: new CustomResourceLoader(),
|
|
331
|
+
pretendToBeVisual: true,
|
|
332
|
+
virtualConsole,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
class FakeXHR {
|
|
336
|
+
constructor() {
|
|
337
|
+
this.readyState = 0;
|
|
338
|
+
this.requestHeaders = [];
|
|
339
|
+
//return traceMethodCalls(this);
|
|
340
|
+
}
|
|
341
|
+
open(method, url) {
|
|
342
|
+
//console.log("open xhr", method, url);
|
|
343
|
+
this.method = method;
|
|
344
|
+
this.url = url;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
addEventListener(ev, reqListener) {
|
|
348
|
+
if (ev === "load") this.reqListener = reqListener;
|
|
349
|
+
}
|
|
350
|
+
setRequestHeader(k, v) {
|
|
351
|
+
this.requestHeaders.push([k, v]);
|
|
352
|
+
}
|
|
353
|
+
overrideMimeType() {}
|
|
354
|
+
async send(body) {
|
|
355
|
+
//console.log("send1", this.url);
|
|
356
|
+
const url1 = this.url.replace("http://localhost", "");
|
|
357
|
+
//console.log("xhr fetching", url1);
|
|
358
|
+
let req =
|
|
359
|
+
this.method == "POST"
|
|
360
|
+
? request(app).post(url1)
|
|
361
|
+
: request(app).get(url1);
|
|
362
|
+
for (const [k, v] of this.requestHeaders) {
|
|
363
|
+
req = req.set(k, v);
|
|
364
|
+
}
|
|
365
|
+
if (this.method === "POST" && body) req.send(body);
|
|
366
|
+
const res = await req;
|
|
367
|
+
this.responseHeaders = res.headers;
|
|
368
|
+
if (res.headers["content-type"].includes("json"))
|
|
369
|
+
this.responseType = "json";
|
|
370
|
+
this.response = res.text;
|
|
371
|
+
this.responseText = res.text;
|
|
372
|
+
this.status = res.status;
|
|
373
|
+
this.statusText = "OK";
|
|
374
|
+
this.readyState = 4;
|
|
375
|
+
if (this.reqListener) this.reqListener(res.text);
|
|
376
|
+
if (this.onload) this.onload(res.text);
|
|
377
|
+
//console.log("agent res", res);
|
|
378
|
+
//console.log("xhr", this);
|
|
379
|
+
}
|
|
380
|
+
getAllResponseHeaders() {
|
|
381
|
+
return Object.entries(this.responseHeaders)
|
|
382
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
383
|
+
.join("\n");
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
dom.window.XMLHttpRequest = FakeXHR;
|
|
387
|
+
await new Promise(function (resolve, reject) {
|
|
388
|
+
dom.window.addEventListener("DOMContentLoaded", (event) => {
|
|
389
|
+
resolve();
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
return dom;
|
|
393
|
+
};
|
|
394
|
+
|
|
310
395
|
module.exports = {
|
|
311
396
|
getStaffLoginCookie,
|
|
312
397
|
getAdminLoginCookie,
|
|
@@ -328,4 +413,5 @@ module.exports = {
|
|
|
328
413
|
succeedJsonWithWholeBody,
|
|
329
414
|
resToLoginCookie,
|
|
330
415
|
itShouldIncludeTextForAdmin,
|
|
416
|
+
load_url_dom,
|
|
331
417
|
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
A table holds data organised by rows and columns, which you can visualise in a
|
|
2
|
+
two-dimensional grid. The rows hold the different cases, about which you have similar data.
|
|
3
|
+
Each column is called a Field, and every field must have a label (and a type).
|
|
4
|
+
|
|
5
|
+
The field label allows you to recognize the meaning of the values in the column in a
|
|
6
|
+
human-readable name. In a spreadsheet with a lot of data, it is often used as the header for
|
|
7
|
+
each column.
|
|
8
|
+
|
|
9
|
+
In Saltcorn, you can use spaces in field labels. Because we also need a valid identifier to use
|
|
10
|
+
in formulae, a variable name is generated from the label by replacing spaces with underscores and
|
|
11
|
+
converting all uppercase characters to lowercase.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Every field in a table stores values of a specific type. The type limits which
|
|
2
|
+
values this field can take in each row. For instance, if a field has a type `Integer`
|
|
3
|
+
then it cannot take the value `"Simon"` but the value `17` is admissible.
|
|
4
|
+
|
|
5
|
+
When building your table, you must therefore think carefully about not only which fields
|
|
6
|
+
to include but also what the types are. Sometimes this is easy - you know that names
|
|
7
|
+
should be stored as as `String` type. But at other times you need to think ahead a bit
|
|
8
|
+
and think carefully about what data your table will hold
|
|
9
|
+
|
|
10
|
+
Some types are more general than others. The `String` type and the `JSON`
|
|
11
|
+
type (from the json module) can hold values that can be represented by more specific types.
|
|
12
|
+
For instance the number 17 can be stored in a string field where it will be represented by the
|
|
13
|
+
string `"17"`. You should always try to use the most specific type available. This will give
|
|
14
|
+
you access to user interface elements that are richer and more appropriate for the data in the
|
|
15
|
+
given field. For example, you could use `String` to store dates, but then you will not
|
|
16
|
+
be able to use an interactive date picker or to display dates in flexible formats.
|
|
17
|
+
|
|
18
|
+
Most field types have some additional parameters that will be configured on the next screen.
|
|
19
|
+
`Integer` types have minimum and maximum values and strings can be restricted to a set of
|
|
20
|
+
options or by a regular expression. This allows you to further narrow what data it is admissible
|
|
21
|
+
in your table.
|
|
22
|
+
|
|
23
|
+
These types are available in your current installation:
|
|
24
|
+
|
|
25
|
+
{{# const tys = Object.values(scState.types) }}
|
|
26
|
+
{{# for (const ty of tys) { }}
|
|
27
|
+
* {{ ty.name }}{{ty.description ? `: ${ty.description}` : ""}}
|
|
28
|
+
{{# } }}
|
|
29
|
+
|
|
30
|
+
Further types can be installed from moduels in the [Module store](/plugins)
|
|
31
|
+
|
|
32
|
+
In addition, a field can have types File and Key to a table.
|
|
33
|
+
|
|
34
|
+
A File field holds a reference to a file in the Saltcorn file system. This is stored by the,
|
|
35
|
+
path to the file and if the file moves, the reference may become invalid.
|
|
36
|
+
|
|
37
|
+
A field that is a Key to another table is a reference to a row in that table. This is also known
|
|
38
|
+
as a foreign key and is used to link data between tables and to create the relational structure
|
|
39
|
+
of your application.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{{# const srcTable = Table.findOne({ name: query.table_name }) }}
|
|
2
|
+
|
|
3
|
+
The row inclusion formula allows you to put further restrictions on the rows that are
|
|
4
|
+
included in the list or feed view. The restrictions from this formula are on top
|
|
5
|
+
of the filter state (from the URL, set by filters), so to be included a row must satisfy
|
|
6
|
+
both the row inclusion Formula and the filter state.
|
|
7
|
+
|
|
8
|
+
The row inclusion formula is a JavaScript expression that must evaluate to a boolean. Typically
|
|
9
|
+
this involves an equality (`==`) or inequality (`<` or `>`) operator.
|
|
10
|
+
|
|
11
|
+
In scope is the variables from your table accessed by their variable name. The fields in the current
|
|
12
|
+
table are:
|
|
13
|
+
|
|
14
|
+
| Field | Variable name | Type |
|
|
15
|
+
| ----- | ------------- | ---- |
|
|
16
|
+
{{# for (const field of srcTable.fields) { }} | {{ field.label }} | `{{ field.name }}` | {{ field.pretty_type }} |
|
|
17
|
+
{{# } }}
|
|
18
|
+
|
|
19
|
+
In addition, you can use the function `today` to compare a Date field against a specific point in time. Depending
|
|
20
|
+
on its argument, `today` will return different date values:
|
|
21
|
+
|
|
22
|
+
* `today()` returns the current time
|
|
23
|
+
* `today(x)` where `x` is a positive number returns the time `x` days in the future. Example: `today(1)` is this time, tomorrow.
|
|
24
|
+
* `today(x)` where `x` is a negative number returns the time `x` days ago. Example: `today(-1)` is this time, yesterday.
|
|
25
|
+
* `today({startOf: unit})` where `unit` is a string, one of: "year", "month", "quarter", "week", "day", "hour",
|
|
26
|
+
"minute" and "second", returns the time at the beginning of the current given time period. Example:
|
|
27
|
+
`today({startOf: "month"})` is the start of the current month.
|
|
28
|
+
* `today({endOf: unit})` is similar to `today({startOf: unit})` but is the end of the given time unit. Example:
|
|
29
|
+
`today({endOf: "week"})` is the end of the current week.
|
|
30
|
+
|
|
31
|
+
### Example
|
|
32
|
+
|
|
33
|
+
If you have a tasks list with a boolean field `done` and a date field `due`, to display all fields where done is `false`
|
|
34
|
+
and are due this week:
|
|
35
|
+
|
|
36
|
+
`done == false &&
|
|
37
|
+
due > today({startOf: "week"}) &&
|
|
38
|
+
due < today({endOf: "week"})`
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
The ability to read or write to tables is normally limited by the settings
|
|
2
|
+
for "Minimum role to read" and "Minimum role to write", which is compared
|
|
3
|
+
to the user's role.
|
|
4
|
+
|
|
5
|
+
In some cases you want some users wo be able to read and write to some but
|
|
6
|
+
not to all rows. Some examples are:
|
|
7
|
+
|
|
8
|
+
* On a blog, Users should be able to create comments and to edit comments they have made.
|
|
9
|
+
But you do not want users to be able to edit another user's comments.
|
|
10
|
+
|
|
11
|
+
* In a todo list, Users should be able to create and edit new items for themselves but
|
|
12
|
+
they should not be able to read or edit items for other users.
|
|
13
|
+
|
|
14
|
+
* In a project management app, you may only want you supposed to be able to see and
|
|
15
|
+
contribute to projects they have been assigned to.
|
|
16
|
+
|
|
17
|
+
Saltcorn contains an authorization system that can be very simple (limit everything by role),
|
|
18
|
+
more flexible (rows have a user field and if you are that user, you can edit a row)
|
|
19
|
+
to very complex (featuring many-to-many relationships, where the user field can be on a
|
|
20
|
+
different table; and inheritance, where authorization schemes propagate through relationship).
|
|
21
|
+
|
|
22
|
+
### Role-based authorization
|
|
23
|
+
|
|
24
|
+
In Saltcorn, every user has a role and the roles have a strictly hierachical ordering,
|
|
25
|
+
which you [can edit](/roleadmin). By
|
|
26
|
+
using the "Minimum role to read" and "Minimum role to write" settings for the table, you
|
|
27
|
+
can create a role cutoff limit for access. See the help topics for those settings for details.
|
|
28
|
+
|
|
29
|
+
### Simple user field ownership
|
|
30
|
+
|
|
31
|
+
In the simplest deviation from role-based authorization, you can grant access to edit
|
|
32
|
+
a row to users that match a Key to users field on the row. To use this, you should:
|
|
33
|
+
|
|
34
|
+
1. Set the "Minimum role to read" and "Minimum role to write" to a role that would stop the
|
|
35
|
+
user from accessing the row.
|
|
36
|
+
|
|
37
|
+
2. Create a field with type Key to users. This field should ideally not be labelled `User` or `user`,
|
|
38
|
+
as this variable name will clash with access to logged-in user object in formulae.
|
|
39
|
+
|
|
40
|
+
3. Make sure this is filled in when the user creates the row.
|
|
41
|
+
For instance in an Edit view under the "Fixed and blocked fields" settings, in the
|
|
42
|
+
Preset for this field pick the LoggedIn preset.
|
|
43
|
+
|
|
44
|
+
4. Pick this field as the Ownership field from the dropdown in the table settings.
|
|
45
|
+
|
|
46
|
+
This is an additional access grant
|
|
47
|
+
in addition to that given by the minimum roles to read and write. If your user does *not*
|
|
48
|
+
match the designated field, The decision to grant access reverts to the role-based settings.
|
|
49
|
+
You therefore cannot use ownership to limit access, only to grant additional access.
|
|
50
|
+
|
|
51
|
+
### Authorization by inheritance
|
|
52
|
+
|
|
53
|
+
If the table has a relationship (that is, has a field with Key type) with another table which
|
|
54
|
+
has an ownership field (or ownership formula), it can instead inherit the onwership from that
|
|
55
|
+
table - essentially, take the ownership of the row the Key is pointing to.
|
|
56
|
+
|
|
57
|
+
For instance, you have a project management app with a Projects table that has an `owner` Key to users
|
|
58
|
+
field and this is set as the ownership field; and a Tasks table with a Key to Projects field.
|
|
59
|
+
In this case "Inherit Projects" is available as an option in the Ownership field dropdown.
|
|
60
|
+
If you pick it and reload the page, you will See that it is in fact implemented as an ownership
|
|
61
|
+
formula which is created for you.
|
|
62
|
+
|
|
63
|
+
### Authorization by user groups
|
|
64
|
+
|
|
65
|
+
If you need to grant access to tables based not on user fields on this table (ownership field)
|
|
66
|
+
or on tables it has keys to (inheritance), you can declare a table to be a user group.
|
|
67
|
+
The user group should be a table that has a key to users, and may also have a key to this table.
|
|
68
|
+
Use this to grant ownership rights to a row to more than one user. For instance, if more than one
|
|
69
|
+
user is working on a project, you can declare that all users assigned to this project are owners. See
|
|
70
|
+
the help topic for the User group option.
|
|
71
|
+
|
|
72
|
+
### Authorisation by formula
|
|
73
|
+
|
|
74
|
+
You can also grant access to edit the row if an arbitrary formula is true. This can be used
|
|
75
|
+
For very flexible authorisation schemes. Choose formula in the ownership field drop down and then see
|
|
76
|
+
help for the Ownership formula.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
An ownership formula on a table allows you to develop an extremely flexible
|
|
2
|
+
Authorisation scheme. It is also the mechanism by which ownership inheritance
|
|
3
|
+
and ownership by user groups is implemented. If you pick one of these options
|
|
4
|
+
in the Ownership field drop-down it will essentially generate the corresponding
|
|
5
|
+
formula for you.
|
|
6
|
+
|
|
7
|
+
The ownership formula is a JavaScript expression which should evaluate to a boolean
|
|
8
|
+
(true/false) value. If it evaluate to true (or a [truthy](https://developer.mozilla.org/en-US/docs/Glossary/Truthy) value), then the logged-in user is an owner and
|
|
9
|
+
can read and write the row. If it evaluate to false (or a [falsey](https://developer.mozilla.org/en-US/docs/Glossary/Falsy) value),
|
|
10
|
+
the user is not the owner. However, If they have a role equal or higher than that
|
|
11
|
+
required to read or write, they can still perform this operation. You cannot use the
|
|
12
|
+
ownership formula to deny access to a user who satisfies the role condition.
|
|
13
|
+
|
|
14
|
+
When evaluating the formula, the values in scope are as follows:
|
|
15
|
+
|
|
16
|
+
#### Row fields
|
|
17
|
+
|
|
18
|
+
All field values for the row being evaluated are in scope and can be addressed by
|
|
19
|
+
their variable name.
|
|
20
|
+
|
|
21
|
+
#### Join fields on row keys
|
|
22
|
+
|
|
23
|
+
For any fields on the current table that are Keys to another table, you can access the values
|
|
24
|
+
in the linked table by using the dot notation. For instance, if you have a field labelled
|
|
25
|
+
"Project" (variable name `project`) which is of type Key to Projects, and the Projects
|
|
26
|
+
table has a `name` field, you can refer to this as `project.name`. However, if the Project field
|
|
27
|
+
is not required, this can trigger an error as accessing a subfield on a `null` variable is a
|
|
28
|
+
JavaScript error. In that case, you can use [optional chaining](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Optional_chaining)
|
|
29
|
+
(`project?.name`) which will evaluate to `null` if `project` is `null`.
|
|
30
|
+
|
|
31
|
+
#### User object
|
|
32
|
+
|
|
33
|
+
You can refer to the logged in user using the variable name user.
|
|
34
|
+
This is an object and you must use the dot to access user fields,
|
|
35
|
+
e.g. user.id for the logged in users id.
|
|
36
|
+
|
|
37
|
+
The user object has other fields than the primary key identifier.
|
|
38
|
+
For your login session the user object is:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
{{JSON.stringify(user, null, 2)}}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
#### User groups
|
|
45
|
+
|
|
46
|
+
If any tables have been designated as user groups (that is, if they User group option has been
|
|
47
|
+
checked in the table settings) then a value will appear in the user object, which is the list
|
|
48
|
+
of an array of user group rows to which the user belongs. The name of that field is
|
|
49
|
+
`{table name}_by_{user field in user group}`.
|
|
50
|
+
|
|
51
|
+
Normally you don't have to write theownership formula, you select it from the Ownership field
|
|
52
|
+
drop-down and the formula is generated for you.
|
|
53
|
+
|
|
54
|
+
When changes are made to user group membership, the user needs to login and log out again before these
|
|
55
|
+
changes are reflected in the user object. If you are removing user group membership you may need to force
|
|
56
|
+
log out those users.
|
|
57
|
+
|
|
58
|
+
## Examples
|
|
59
|
+
|
|
60
|
+
User table has a `String` field named `department` which can take options "Finance", "HR",
|
|
61
|
+
or "Warp drive engineering". To give ownership to the Expenses table to users in the Finance
|
|
62
|
+
department:
|
|
63
|
+
|
|
64
|
+
`user.department === "Finance"`
|
|
65
|
+
|
|
66
|
+
Same as above, but the Expenses table also has a `filled_by` field which is Key to user, and
|
|
67
|
+
you want to give ownership to that user or any user in the Finance department:
|
|
68
|
+
|
|
69
|
+
`user.department === "Finance" || user.id === filled_by`
|
|
70
|
+
|
|
71
|
+
Similar to above, however department membership is not stored as User field but as in a table
|
|
72
|
+
named "User In Department" with a Key to user field labelled "Employee" and a string field "Name" for
|
|
73
|
+
the department name.
|
|
74
|
+
|
|
75
|
+
`user.UserinDepartment_by_employee.map(d=>d.name).includes("Finance") || user.id === filled_by`
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
You can restrict which users can read or write to this table by their role.
|
|
2
|
+
|
|
3
|
+
Each user in Saltcorn has a role and the roles have a strictly hierachical ordering,
|
|
4
|
+
which you [can edit](/roleadmin). The ordering means that users in a role can access
|
|
5
|
+
everything the users in the role "below" then can acceess, but the users in the role
|
|
6
|
+
"above" have further access.
|
|
7
|
+
|
|
8
|
+
Assigning access by role is a quick way to give users more or less access based on how
|
|
9
|
+
much you trust them.
|
|
10
|
+
|
|
11
|
+
Using the settings for "Minimum role to read" and "Minimum role to write" you set the roles
|
|
12
|
+
required to read and write to the table, respectively. Users also need to have the roles
|
|
13
|
+
required for running views and pages.
|
|
14
|
+
|
|
15
|
+
Restricting table access by role is the simplest form of authorisation in Saltcorn,
|
|
16
|
+
but it is often too limited. Row ownership is much more flexible; see the help topic for
|
|
17
|
+
Ownership field.
|
|
18
|
+
|
|
19
|
+
Note that if the user has ownership of the row, they can read and write that row even if
|
|
20
|
+
they have a role below the minimum role to read and write, respectively.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
User groups are used to grant access to tables based on membership of groups
|
|
2
|
+
where each user can be a member of many groups and each group can have many
|
|
3
|
+
users.
|
|
4
|
+
|
|
5
|
+
Any table that has a field of type Key to User can be designated a user group, by
|
|
6
|
+
checking the User group option. This means that when a User group table has a row with
|
|
7
|
+
a key to a user, that user is a member of a group.
|
|
8
|
+
|
|
9
|
+
The consequence of designating a table as a User group is that if there is also a Key from
|
|
10
|
+
the user group table to another table, then the option of group membership appears in the
|
|
11
|
+
drop-down for the Ownership field option.
|
|
12
|
+
|
|
13
|
+
In addition, If a table is designated as a user group. a value indicating group membership
|
|
14
|
+
appears in the user object. For this to appear, the variable has to be referenced in an
|
|
15
|
+
ownership formula. The name of this variable is
|
|
16
|
+
`{user group table name}_by_{key to user field name}`.
|
|
17
|
+
|
|
18
|
+
When changes are made to user group membership, the user needs to login and log out again before these
|
|
19
|
+
changes are reflected in the user object. If you are removing user group membership you may need to force
|
|
20
|
+
log out those users.
|
|
21
|
+
|
|
22
|
+
### Example
|
|
23
|
+
|
|
24
|
+
In a project management application you have a "Projects" table and a "Tasks" table (with a
|
|
25
|
+
Key to project). Several people can work on the same project.
|
|
26
|
+
|
|
27
|
+
You would like to restrict access to the project table such that only uses to work on the project have access.
|
|
28
|
+
|
|
29
|
+
1. Create a "User Works On Project" table with a Key to projects field and a Key to user field called Participant.
|
|
30
|
+
|
|
31
|
+
2. Designate the "User Works On Project" table as a user group by checking the user group box.
|
|
32
|
+
|
|
33
|
+
3. In the project table settings, In the ownership field, there is now an option called "In User Works On Project by Participant", pick this option.
|
|
34
|
+
|
|
35
|
+
4. Now add rows to the "User Works On Project" table. When the users logout and login again they will have the required access.
|