@openinc/parse-server-opendash 4.2.4 → 4.3.1
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/dist/features/config/helper/customOptions.js +1 -1
- package/dist/features/notifications/services/initEmailTransport.js +3 -1
- package/dist/features/notifications/services/sendMails.js +2 -2
- package/dist/features/notifications/states/EmailState.d.ts +6 -0
- package/dist/features/notifications/states/EmailState.js +16 -0
- package/dist/features/permissions/types/Permissions.d.ts +2 -1
- package/dist/features/permissions/types/Permissions.js +3 -0
- package/dist/functions/openinc-repair-meta-entry-acl.d.ts +24 -0
- package/dist/functions/openinc-repair-meta-entry-acl.js +59 -0
- package/dist/functions/openinc-restore-deleted-object.d.ts +4 -0
- package/dist/functions/openinc-restore-deleted-object.js +343 -0
- package/dist/hooks/Meta_Entry.js +46 -1
- package/dist/hooks/Service_Ticket.js +20 -4
- package/dist/templates/emails/openinc-auth-password-reset.html +61 -0
- package/dist/templates/emails/openinc-auth-password-reset.subject +1 -0
- package/dist/templates/emails/openinc-auth-password-reset.txt +27 -0
- package/package.json +3 -3
|
@@ -266,7 +266,7 @@ exports.customoptions = {
|
|
|
266
266
|
required: false,
|
|
267
267
|
secret: false,
|
|
268
268
|
public: false,
|
|
269
|
-
description: "The directory where the email templates are stored. The default is 'views/emails'.",
|
|
269
|
+
description: "The directory where the email templates are stored. The default is 'views/emails'. Templates placed here override the ones shipped with this package; if neither provides a template, the fallback text of the sending code is used.",
|
|
270
270
|
default: "views/emails",
|
|
271
271
|
},
|
|
272
272
|
SMTP_ENABLED: {
|
|
@@ -36,7 +36,9 @@ async function initEmailTransport() {
|
|
|
36
36
|
emailState.setTransport(transport);
|
|
37
37
|
const templateDir = (0, path_1.resolve)(process.cwd(), index_js_1.ConfigInstance.getInstance().get("EMAIL_TEMPLATE_DIR"));
|
|
38
38
|
emailState.setEmailTemplateDir(templateDir);
|
|
39
|
-
nunjucks_1.default.configure(
|
|
39
|
+
nunjucks_1.default.configure(emailState.getEmailTemplateDirs(), {
|
|
40
|
+
autoescape: true,
|
|
41
|
+
});
|
|
40
42
|
console.log("[@openinc/parse-server-opendash] Email transport initialized successfully");
|
|
41
43
|
}
|
|
42
44
|
else {
|
|
@@ -71,9 +71,9 @@ function validateEmailTemplate(templateDir, template) {
|
|
|
71
71
|
*/
|
|
72
72
|
function renderEmailTemplate(type, template, data, fallback) {
|
|
73
73
|
const emailState = EmailState_js_1.EmailState.getInstance();
|
|
74
|
-
const
|
|
74
|
+
const emailTemplateDirs = emailState.getEmailTemplateDirs();
|
|
75
75
|
const fullTemplate = template + "." + type;
|
|
76
|
-
if (validateEmailTemplate(
|
|
76
|
+
if (emailTemplateDirs.some((dir) => validateEmailTemplate(dir, fullTemplate))) {
|
|
77
77
|
return nunjucks_1.default.render(fullTemplate, data);
|
|
78
78
|
}
|
|
79
79
|
if (type === "html") {
|
|
@@ -28,6 +28,12 @@ export declare class EmailState {
|
|
|
28
28
|
* Get the email template directory
|
|
29
29
|
*/
|
|
30
30
|
getEmailTemplateDir(): string;
|
|
31
|
+
/**
|
|
32
|
+
* Get all directories a template is searched in, in order of precedence:
|
|
33
|
+
* the deployment specific directory first, the templates shipped with this
|
|
34
|
+
* package second.
|
|
35
|
+
*/
|
|
36
|
+
getEmailTemplateDirs(): string[];
|
|
31
37
|
/**
|
|
32
38
|
* Check if email transport is configured and ready
|
|
33
39
|
*/
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.EmailState = void 0;
|
|
4
|
+
const path_1 = require("path");
|
|
5
|
+
/**
|
|
6
|
+
* Email templates shipped with this package. They are used whenever the
|
|
7
|
+
* deployment specific template directory does not provide a template of the
|
|
8
|
+
* same name. Resolved relative to this file, so it works both for the compiled
|
|
9
|
+
* output (dist/templates/emails) and for the sources (src/templates/emails).
|
|
10
|
+
*/
|
|
11
|
+
const BUILTIN_EMAIL_TEMPLATE_DIR = (0, path_1.resolve)(__dirname, "../../../templates/emails");
|
|
4
12
|
/**
|
|
5
13
|
* Singleton class to manage email transport and template directory
|
|
6
14
|
*/
|
|
@@ -42,6 +50,14 @@ class EmailState {
|
|
|
42
50
|
getEmailTemplateDir() {
|
|
43
51
|
return this.emailTemplateDir;
|
|
44
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Get all directories a template is searched in, in order of precedence:
|
|
55
|
+
* the deployment specific directory first, the templates shipped with this
|
|
56
|
+
* package second.
|
|
57
|
+
*/
|
|
58
|
+
getEmailTemplateDirs() {
|
|
59
|
+
return [this.emailTemplateDir, BUILTIN_EMAIL_TEMPLATE_DIR];
|
|
60
|
+
}
|
|
45
61
|
/**
|
|
46
62
|
* Check if email transport is configured and ready
|
|
47
63
|
*/
|
|
@@ -40,7 +40,8 @@ export declare namespace Permissions {
|
|
|
40
40
|
rmqadmin = "parse-admin:can-access-rmq-admin",
|
|
41
41
|
metaadmin = "meta:can-see-admin",
|
|
42
42
|
chatadmin = "chat:can-see-admin",
|
|
43
|
-
changelog = "changelog:can-see-app"
|
|
43
|
+
changelog = "changelog:can-see-app",
|
|
44
|
+
changelog_restore = "changelog:can-restore-deleted"
|
|
44
45
|
}
|
|
45
46
|
enum GTFS {
|
|
46
47
|
global = "gtfs:can-access-global"
|
|
@@ -70,6 +70,9 @@ var Permissions;
|
|
|
70
70
|
CORE["metaadmin"] = "meta:can-see-admin";
|
|
71
71
|
CORE["chatadmin"] = "chat:can-see-admin";
|
|
72
72
|
CORE["changelog"] = "changelog:can-see-app";
|
|
73
|
+
// Restoring a deleted object writes with the master key, so it is kept
|
|
74
|
+
// separate from merely reading the changelog.
|
|
75
|
+
CORE["changelog_restore"] = "changelog:can-restore-deleted";
|
|
73
76
|
})(CORE = Permissions.CORE || (Permissions.CORE = {}));
|
|
74
77
|
let GTFS;
|
|
75
78
|
(function (GTFS) {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repairs the ACL of one meta entry — a Parse Dashboard script, run from
|
|
3
|
+
* "Run script on selected row" in the data browser.
|
|
4
|
+
*
|
|
5
|
+
* Rows written before the write grants in `hooks/Meta_Entry.ts` existed came
|
|
6
|
+
* out writable only by `od-admin` and the tenant admins. A plain tenant user
|
|
7
|
+
* could create such an entry and never update it again: Parse answers the
|
|
8
|
+
* denied write with `101 Object not found`. The hook fixes every write from
|
|
9
|
+
* here on, but it only sees writes, and the affected users are exactly the ones
|
|
10
|
+
* who cannot make one — so the repair has to come from a master-key caller.
|
|
11
|
+
*
|
|
12
|
+
* All this does is re-save the row. The JS SDK issues the request even when
|
|
13
|
+
* nothing is dirty, and the beforeSave triggers run for master-key writes, so
|
|
14
|
+
* `defaultAclHandler` rebuilds the ACL and the hook layers the current grants
|
|
15
|
+
* back on. Nothing else about the row changes, and running it on an already
|
|
16
|
+
* correct row is a no-op — so it is safe to select everything and run it once.
|
|
17
|
+
*
|
|
18
|
+
* Registered for `OD3_Meta_Entry` only. The same re-save would rebuild the ACL
|
|
19
|
+
* of any class whose `beforeSave` calls `defaultAclHandler`, but the narrower
|
|
20
|
+
* the blast radius of a one-click dashboard action, the better.
|
|
21
|
+
*
|
|
22
|
+
* @param name The function name, derived from the file name.
|
|
23
|
+
*/
|
|
24
|
+
export declare function init(name: string): Promise<void>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.init = init;
|
|
7
|
+
const node_1 = __importDefault(require("parse/node"));
|
|
8
|
+
const index_js_1 = require("../types/index.js");
|
|
9
|
+
/**
|
|
10
|
+
* Repairs the ACL of one meta entry — a Parse Dashboard script, run from
|
|
11
|
+
* "Run script on selected row" in the data browser.
|
|
12
|
+
*
|
|
13
|
+
* Rows written before the write grants in `hooks/Meta_Entry.ts` existed came
|
|
14
|
+
* out writable only by `od-admin` and the tenant admins. A plain tenant user
|
|
15
|
+
* could create such an entry and never update it again: Parse answers the
|
|
16
|
+
* denied write with `101 Object not found`. The hook fixes every write from
|
|
17
|
+
* here on, but it only sees writes, and the affected users are exactly the ones
|
|
18
|
+
* who cannot make one — so the repair has to come from a master-key caller.
|
|
19
|
+
*
|
|
20
|
+
* All this does is re-save the row. The JS SDK issues the request even when
|
|
21
|
+
* nothing is dirty, and the beforeSave triggers run for master-key writes, so
|
|
22
|
+
* `defaultAclHandler` rebuilds the ACL and the hook layers the current grants
|
|
23
|
+
* back on. Nothing else about the row changes, and running it on an already
|
|
24
|
+
* correct row is a no-op — so it is safe to select everything and run it once.
|
|
25
|
+
*
|
|
26
|
+
* Registered for `OD3_Meta_Entry` only. The same re-save would rebuild the ACL
|
|
27
|
+
* of any class whose `beforeSave` calls `defaultAclHandler`, but the narrower
|
|
28
|
+
* the blast radius of a one-click dashboard action, the better.
|
|
29
|
+
*
|
|
30
|
+
* @param name The function name, derived from the file name.
|
|
31
|
+
*/
|
|
32
|
+
async function init(name) {
|
|
33
|
+
node_1.default.Cloud.define(name, async function (request) {
|
|
34
|
+
const object = request.params.object;
|
|
35
|
+
// Thrown rather than returned: the dashboard surfaces a rejection as an
|
|
36
|
+
// error toast, while any resolved value reads as success.
|
|
37
|
+
if (!object) {
|
|
38
|
+
throw new node_1.default.Error(node_1.default.Error.INVALID_JSON, "No object was passed. Run this from the data browser on a selected row.");
|
|
39
|
+
}
|
|
40
|
+
if (object.className !== index_js_1.Meta_Entry.className) {
|
|
41
|
+
throw new node_1.default.Error(node_1.default.Error.INVALID_CLASS_NAME, `Expected a ${index_js_1.Meta_Entry.className} row, got ${object.className}.`);
|
|
42
|
+
}
|
|
43
|
+
await object.save(null, { useMasterKey: true });
|
|
44
|
+
const acl = object.getACL();
|
|
45
|
+
const subjectId = object.get("entryObjectId");
|
|
46
|
+
return {
|
|
47
|
+
success: true,
|
|
48
|
+
id: object.id,
|
|
49
|
+
context: object.get("context"),
|
|
50
|
+
entryClassName: object.get("entryClassName"),
|
|
51
|
+
// Reported back so the dashboard shows whether the grant actually
|
|
52
|
+
// landed, rather than just "done".
|
|
53
|
+
subjectCanWrite: !!subjectId && !!acl?.getWriteAccess(subjectId),
|
|
54
|
+
};
|
|
55
|
+
}, {
|
|
56
|
+
// Parse Dashboard runs scripts with the master key.
|
|
57
|
+
requireMaster: true,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.init = init;
|
|
7
|
+
const node_1 = __importDefault(require("parse/node"));
|
|
8
|
+
const index_js_1 = require("../features/permissions/index.js");
|
|
9
|
+
const index_js_2 = require("../types/index.js");
|
|
10
|
+
/**
|
|
11
|
+
* Restores an object that was deleted, from its `OD3_Changelog` delete entry.
|
|
12
|
+
*
|
|
13
|
+
* Two callers, one contract — both hand over a pointer to the changelog entry
|
|
14
|
+
* and get back a one-line summary to show the user:
|
|
15
|
+
* - the Parse Dashboard, via "Run script on selected row" in the data browser
|
|
16
|
+
* (master key);
|
|
17
|
+
* - the opendash changelog admin view, via its per-row restore action
|
|
18
|
+
* (session token plus `changelog:can-restore-deleted`).
|
|
19
|
+
*
|
|
20
|
+
* `hooks/_ChangeLog.ts` writes `original: object.toJSON()` on every
|
|
21
|
+
* `beforeDelete`, which is a complete snapshot: every field, the ACL, and the
|
|
22
|
+
* objectId. That snapshot is enough to put the row back, so the restore reads
|
|
23
|
+
* the changelog entry rather than asking the caller for anything.
|
|
24
|
+
*
|
|
25
|
+
* It is offered on `OD3_Changelog` rows only, and that is the only placement
|
|
26
|
+
* that can work: a deleted object has no row of its own left to select. It
|
|
27
|
+
* restores into whichever class the entry names, so every class is a valid
|
|
28
|
+
* restore target.
|
|
29
|
+
*
|
|
30
|
+
* **Schema drift.** The snapshot describes the class as it looked at deletion
|
|
31
|
+
* time, and the class may have moved on since. Every field is therefore checked
|
|
32
|
+
* against the *current* schema before it is written, and anything that no longer
|
|
33
|
+
* fits is dropped from the payload and named in the result rather than being
|
|
34
|
+
* forced through — a mismatched write would either fail with a Parse error that
|
|
35
|
+
* names one field at a time, or quietly land a value of the wrong type. The
|
|
36
|
+
* cases handled are: the field was removed, its type changed, a pointer's
|
|
37
|
+
* targetClass changed, and it is a Relation (which `toJSON()` records as a
|
|
38
|
+
* marker with no members, so there is nothing to restore). Fields that became
|
|
39
|
+
* required *after* the deletion are the one drift that blocks the restore, since
|
|
40
|
+
* Parse would reject the create anyway — they are collected and reported
|
|
41
|
+
* together instead of one per attempt.
|
|
42
|
+
*
|
|
43
|
+
* **objectId.** Keeping the original id is what makes inbound pointers resolve
|
|
44
|
+
* again, but `parse-server/lib/RestWrite.js` rejects `objectId` on create unless
|
|
45
|
+
* the server runs with `allowCustomObjectId`. Rather than depend on a
|
|
46
|
+
* server-wide flag being set, the create is attempted with the original id and
|
|
47
|
+
* retried without it if the server refuses. That probe is free: the rejection
|
|
48
|
+
* happens in the `RestWrite` constructor, before any trigger or write.
|
|
49
|
+
*
|
|
50
|
+
* **Timestamps.** `createdAt` / `updatedAt` cannot be carried over. Parse only
|
|
51
|
+
* honours them on create for a *maintenance*-key write, and that path additionally
|
|
52
|
+
* needs `OD_MIGRATION_MODE=1` server-side or `defaultHandler` fails on the
|
|
53
|
+
* missing user (see `scripts/migration/lib/env.ts`), which is not a state a
|
|
54
|
+
* general restore can assume. The restored row is therefore stamped `now`; the
|
|
55
|
+
* original timestamps stay readable on the changelog entry.
|
|
56
|
+
*
|
|
57
|
+
* The write goes through the ordinary REST create with the master key, so
|
|
58
|
+
* `beforeSave` runs as it would for any other create. For classes whose hook
|
|
59
|
+
* calls `defaultAclHandler` that means the restored ACL is rebuilt rather than
|
|
60
|
+
* taken verbatim from the snapshot — the same ACL such a row would get today.
|
|
61
|
+
*/
|
|
62
|
+
/** Snapshot keys that are not ordinary field data. */
|
|
63
|
+
const RESERVED = new Set([
|
|
64
|
+
"objectId",
|
|
65
|
+
"className",
|
|
66
|
+
"__type",
|
|
67
|
+
"createdAt",
|
|
68
|
+
"updatedAt",
|
|
69
|
+
"ACL",
|
|
70
|
+
]);
|
|
71
|
+
/**
|
|
72
|
+
* The Parse field type a snapshot value would need.
|
|
73
|
+
*
|
|
74
|
+
* `null` means "carries no type information" — a null value is assignable to any
|
|
75
|
+
* optional field, so it is never a drift mismatch on its own.
|
|
76
|
+
*/
|
|
77
|
+
function snapshotType(value) {
|
|
78
|
+
if (value === null || value === undefined)
|
|
79
|
+
return null;
|
|
80
|
+
if (Array.isArray(value))
|
|
81
|
+
return "Array";
|
|
82
|
+
switch (typeof value) {
|
|
83
|
+
case "string":
|
|
84
|
+
return "String";
|
|
85
|
+
case "number":
|
|
86
|
+
return "Number";
|
|
87
|
+
case "boolean":
|
|
88
|
+
return "Boolean";
|
|
89
|
+
}
|
|
90
|
+
const tagged = value;
|
|
91
|
+
switch (tagged.__type) {
|
|
92
|
+
case "Pointer":
|
|
93
|
+
return "Pointer";
|
|
94
|
+
case "Date":
|
|
95
|
+
return "Date";
|
|
96
|
+
case "File":
|
|
97
|
+
return "File";
|
|
98
|
+
case "GeoPoint":
|
|
99
|
+
return "GeoPoint";
|
|
100
|
+
case "Polygon":
|
|
101
|
+
return "Polygon";
|
|
102
|
+
case "Bytes":
|
|
103
|
+
return "Bytes";
|
|
104
|
+
case "Relation":
|
|
105
|
+
return "Relation";
|
|
106
|
+
}
|
|
107
|
+
return "Object";
|
|
108
|
+
}
|
|
109
|
+
function restHeaders() {
|
|
110
|
+
return {
|
|
111
|
+
"X-Parse-Application-Id": node_1.default.applicationId,
|
|
112
|
+
"X-Parse-Master-Key": node_1.default.masterKey,
|
|
113
|
+
"Content-Type": "application/json",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* GET one row as raw REST JSON.
|
|
118
|
+
*
|
|
119
|
+
* Deliberately not `object.fetch()`. The SDK's `decode()` recurses into
|
|
120
|
+
* Object-typed fields and turns every `{__type: "Pointer" | "Date" | "File" | …}`
|
|
121
|
+
* marker it finds into a live SDK instance, so the snapshot would arrive as a mix
|
|
122
|
+
* of plain JSON and objects. Two things break then: a Pointer reads as type
|
|
123
|
+
* "Object" when checked against the schema and gets dropped as false drift, and
|
|
124
|
+
* `JSON.stringify` re-encodes a `Parse.Object` as its full attributes instead of a
|
|
125
|
+
* pointer. Reading over REST keeps the snapshot in exactly the shape it was
|
|
126
|
+
* stored in — which is also the shape the create below expects.
|
|
127
|
+
*/
|
|
128
|
+
async function restGet(className, objectId) {
|
|
129
|
+
const response = await fetch(`${node_1.default.serverURL}/classes/${encodeURIComponent(className)}/${encodeURIComponent(objectId)}`, { method: "GET", headers: restHeaders() });
|
|
130
|
+
if (!response.ok) {
|
|
131
|
+
throw new node_1.default.Error(node_1.default.Error.OBJECT_NOT_FOUND, `Could not read ${className} ${objectId}: HTTP ${response.status}.`);
|
|
132
|
+
}
|
|
133
|
+
return (await response.json());
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* POST a create straight to REST.
|
|
137
|
+
*
|
|
138
|
+
* The SDK cannot do this one: `Parse.Object.fromJSON` marks an object with an id
|
|
139
|
+
* as already saved, so `save()` would issue an update against a row that is gone.
|
|
140
|
+
*/
|
|
141
|
+
async function restCreate(className, body) {
|
|
142
|
+
const response = await fetch(`${node_1.default.serverURL}/classes/${encodeURIComponent(className)}`, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: restHeaders(),
|
|
145
|
+
body: JSON.stringify(body),
|
|
146
|
+
});
|
|
147
|
+
const text = await response.text();
|
|
148
|
+
let parsed = {};
|
|
149
|
+
try {
|
|
150
|
+
parsed = JSON.parse(text);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
status: response.status,
|
|
156
|
+
error: text.slice(0, 300) || `HTTP ${response.status}`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return response.ok
|
|
160
|
+
? { ok: true, status: response.status, objectId: parsed.objectId }
|
|
161
|
+
: {
|
|
162
|
+
ok: false,
|
|
163
|
+
status: response.status,
|
|
164
|
+
code: parsed.code,
|
|
165
|
+
error: parsed.error,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/** Does `objectId` still exist in `className`? Errors count as "no". */
|
|
169
|
+
async function exists(className, objectId) {
|
|
170
|
+
try {
|
|
171
|
+
const query = new node_1.default.Query(className);
|
|
172
|
+
query.equalTo("objectId", objectId);
|
|
173
|
+
return (await query.count({ useMasterKey: true })) > 0;
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* @param name The function name, derived from the file name.
|
|
181
|
+
*/
|
|
182
|
+
async function init(name) {
|
|
183
|
+
node_1.default.Cloud.define(name, async function (request) {
|
|
184
|
+
// Master-key callers (the Parse Dashboard script) pass straight through;
|
|
185
|
+
// a session caller (the opendash changelog admin view) needs the grant.
|
|
186
|
+
// The restore itself always writes with the master key, which is why this
|
|
187
|
+
// is a permission of its own rather than part of `changelog:can-see-app`.
|
|
188
|
+
await (0, index_js_1.requirePermission)({
|
|
189
|
+
sessionToken: request.user?.getSessionToken(),
|
|
190
|
+
master: request.master,
|
|
191
|
+
}, index_js_1.Permissions.CORE.changelog_restore, "You are not allowed to restore deleted objects");
|
|
192
|
+
// The dashboard sends `createWithoutData(id).toPointer()`, so this arrives
|
|
193
|
+
// as an unfetched shell — id and className only, no field data.
|
|
194
|
+
const pointer = request.params.object;
|
|
195
|
+
// Thrown rather than returned: the dashboard surfaces a rejection as an
|
|
196
|
+
// error toast, while any resolved value reads as success.
|
|
197
|
+
if (!pointer) {
|
|
198
|
+
throw new node_1.default.Error(node_1.default.Error.INVALID_JSON, "No object was passed. Run this from the data browser on a selected row.");
|
|
199
|
+
}
|
|
200
|
+
if (pointer.className !== index_js_2.Changelog.className) {
|
|
201
|
+
throw new node_1.default.Error(node_1.default.Error.INVALID_CLASS_NAME, `Expected a ${index_js_2.Changelog.className} row, got ${pointer.className}. ` +
|
|
202
|
+
"Select the delete entry of the object you want back.");
|
|
203
|
+
}
|
|
204
|
+
if (!pointer.id) {
|
|
205
|
+
throw new node_1.default.Error(node_1.default.Error.INVALID_JSON, "The selected row has no objectId.");
|
|
206
|
+
}
|
|
207
|
+
const entry = await restGet(index_js_2.Changelog.className, pointer.id);
|
|
208
|
+
const operation = entry.operation;
|
|
209
|
+
if (operation !== "delete") {
|
|
210
|
+
throw new node_1.default.Error(node_1.default.Error.INVALID_QUERY, `This entry records a "${operation}", not a delete — there is nothing to restore. ` +
|
|
211
|
+
'Filter the browser on operation = "delete".');
|
|
212
|
+
}
|
|
213
|
+
const className = entry.nameOfClass;
|
|
214
|
+
const snapshot = entry.original;
|
|
215
|
+
if (!className) {
|
|
216
|
+
throw new node_1.default.Error(node_1.default.Error.INVALID_JSON, "The entry has no nameOfClass, so the target class is unknown.");
|
|
217
|
+
}
|
|
218
|
+
if (!snapshot || typeof snapshot !== "object") {
|
|
219
|
+
throw new node_1.default.Error(node_1.default.Error.INVALID_JSON, "The entry has no `original` snapshot, so there is nothing to restore from. " +
|
|
220
|
+
"Entries written while the delete hook was failing can look like this.");
|
|
221
|
+
}
|
|
222
|
+
const originalId = snapshot.objectId ??
|
|
223
|
+
entry.changedObject;
|
|
224
|
+
// The class itself may be gone, which Parse reports as a failure to read
|
|
225
|
+
// its schema. Say so plainly instead of leaking the schema error.
|
|
226
|
+
let fields;
|
|
227
|
+
try {
|
|
228
|
+
// `get()` takes no options — the SDK forces useMasterKey internally.
|
|
229
|
+
const schema = await new node_1.default.Schema(className).get();
|
|
230
|
+
fields = (schema.fields ?? {});
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
throw new node_1.default.Error(node_1.default.Error.INVALID_CLASS_NAME, `Class ${className} no longer exists, so its rows cannot be restored.`);
|
|
234
|
+
}
|
|
235
|
+
if (originalId && (await exists(className, originalId))) {
|
|
236
|
+
throw new node_1.default.Error(node_1.default.Error.DUPLICATE_VALUE, `${className} ${originalId} already exists — it was restored already, or the id was reused. ` +
|
|
237
|
+
"Nothing was written.");
|
|
238
|
+
}
|
|
239
|
+
// Walk the snapshot against the current schema.
|
|
240
|
+
const payload = {};
|
|
241
|
+
const skipped = [];
|
|
242
|
+
const warnings = [];
|
|
243
|
+
const pointerFields = [];
|
|
244
|
+
let restoredCount = 0;
|
|
245
|
+
for (const [key, value] of Object.entries(snapshot)) {
|
|
246
|
+
if (RESERVED.has(key))
|
|
247
|
+
continue;
|
|
248
|
+
const field = fields[key];
|
|
249
|
+
if (!field) {
|
|
250
|
+
skipped.push(`${key} (field no longer exists)`);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (field.type === "Relation") {
|
|
254
|
+
// toJSON() writes `{__type: "Relation", className}` — a marker, not the
|
|
255
|
+
// members. Restoring the row cannot restore what it pointed at.
|
|
256
|
+
skipped.push(`${key} (Relation — members are not in the snapshot)`);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const type = snapshotType(value);
|
|
260
|
+
if (type !== null && type !== field.type) {
|
|
261
|
+
skipped.push(`${key} (type changed ${type} → ${field.type})`);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (type === "Pointer") {
|
|
265
|
+
const p = value;
|
|
266
|
+
if (field.targetClass && field.targetClass !== p.className) {
|
|
267
|
+
skipped.push(`${key} (pointer target changed ${p.className} → ${field.targetClass})`);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
pointerFields.push({ key, className: p.className, id: p.objectId });
|
|
271
|
+
}
|
|
272
|
+
payload[key] = value;
|
|
273
|
+
restoredCount++;
|
|
274
|
+
}
|
|
275
|
+
// Fields that became required after the deletion would make Parse reject
|
|
276
|
+
// the create one field at a time. Collect them and report them together.
|
|
277
|
+
const missingRequired = Object.entries(fields)
|
|
278
|
+
.filter(([key, field]) => field.required &&
|
|
279
|
+
field.defaultValue === undefined &&
|
|
280
|
+
!RESERVED.has(key) &&
|
|
281
|
+
(payload[key] === undefined || payload[key] === null))
|
|
282
|
+
.map(([key, field]) => `${key} (${field.type})`);
|
|
283
|
+
if (missingRequired.length > 0) {
|
|
284
|
+
throw new node_1.default.Error(node_1.default.Error.VALIDATION_ERROR, `${className} has required fields the snapshot cannot fill: ${missingRequired.join(", ")}. ` +
|
|
285
|
+
"They were added after this object was deleted. Nothing was written — " +
|
|
286
|
+
"give them a defaultValue in the schema, or restore the row by hand.");
|
|
287
|
+
}
|
|
288
|
+
// Pointers to objects that are themselves gone still restore (Parse does
|
|
289
|
+
// not enforce referential integrity), but they are worth naming.
|
|
290
|
+
for (const p of pointerFields) {
|
|
291
|
+
if (!(await exists(p.className, p.id))) {
|
|
292
|
+
warnings.push(`${p.key} → ${p.className} ${p.id} no longer exists`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (snapshot.ACL)
|
|
296
|
+
payload.ACL = snapshot.ACL;
|
|
297
|
+
if (className === "_User") {
|
|
298
|
+
// toJSON() never carries the password hash.
|
|
299
|
+
warnings.push("restored _User rows have no password — the user must reset it");
|
|
300
|
+
}
|
|
301
|
+
// Try to keep the original id; fall back to a generated one when the
|
|
302
|
+
// server runs without allowCustomObjectId.
|
|
303
|
+
let keptId = true;
|
|
304
|
+
let result;
|
|
305
|
+
if (originalId) {
|
|
306
|
+
result = await restCreate(className, {
|
|
307
|
+
...payload,
|
|
308
|
+
objectId: originalId,
|
|
309
|
+
});
|
|
310
|
+
if (!result.ok && result.code === node_1.default.Error.INVALID_KEY_NAME) {
|
|
311
|
+
keptId = false;
|
|
312
|
+
result = await restCreate(className, payload);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
keptId = false;
|
|
317
|
+
warnings.push("the snapshot had no objectId to restore");
|
|
318
|
+
result = await restCreate(className, payload);
|
|
319
|
+
}
|
|
320
|
+
if (!result.ok) {
|
|
321
|
+
throw new node_1.default.Error(node_1.default.Error.INTERNAL_SERVER_ERROR, `Restore of ${className} ${originalId ?? ""} failed: ${result.error ?? `HTTP ${result.status}`}`);
|
|
322
|
+
}
|
|
323
|
+
const newId = result.objectId ?? "?";
|
|
324
|
+
const parts = [
|
|
325
|
+
`Restored ${className} ${newId} ` +
|
|
326
|
+
(keptId
|
|
327
|
+
? "with its original objectId."
|
|
328
|
+
: `under a NEW objectId (server has no allowCustomObjectId; original was ${originalId}). ` +
|
|
329
|
+
"Pointers to the old id still dangle."),
|
|
330
|
+
`${restoredCount} field(s) restored.`,
|
|
331
|
+
"createdAt/updatedAt are set to now; the originals stay on the changelog entry.",
|
|
332
|
+
];
|
|
333
|
+
if (skipped.length > 0) {
|
|
334
|
+
parts.push(`Skipped ${skipped.length} field(s): ${skipped.join("; ")}.`);
|
|
335
|
+
}
|
|
336
|
+
if (warnings.length > 0) {
|
|
337
|
+
parts.push(`Warnings: ${warnings.join("; ")}.`);
|
|
338
|
+
}
|
|
339
|
+
// Returned as a string: the dashboard passes the result straight to
|
|
340
|
+
// showNote(), which renders an object as "[object Object]".
|
|
341
|
+
return parts.join(" ");
|
|
342
|
+
});
|
|
343
|
+
}
|
package/dist/hooks/Meta_Entry.js
CHANGED
|
@@ -1,13 +1,58 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.init = init;
|
|
7
|
+
const node_1 = __importDefault(require("parse/node"));
|
|
4
8
|
const index_js_1 = require("../features/meta/index.js");
|
|
5
9
|
const index_js_2 = require("../features/schema/index.js");
|
|
6
10
|
const index_js_3 = require("../types/index.js");
|
|
11
|
+
/**
|
|
12
|
+
* Grant the user an entry is *about* write access to it.
|
|
13
|
+
*
|
|
14
|
+
* `defaultAclHandler` keys its owner grant on a `user` field, and `Meta_Entry`
|
|
15
|
+
* has none — it addresses its subject by `entryClassName` + `entryObjectId`
|
|
16
|
+
* instead. So a `_User` entry came out writable only by `od-admin` and the
|
|
17
|
+
* tenant admins, while a plain tenant user got read-only: they could create
|
|
18
|
+
* their entry (the CLP allows it) and never update it again, with Parse
|
|
19
|
+
* answering the denied write as `101 Object not found`.
|
|
20
|
+
*
|
|
21
|
+
* These entries are the user's own data — their saved dashboard views, their
|
|
22
|
+
* employee number — and the user is who writes them from the UI, so the grant
|
|
23
|
+
* belongs to them. Read access is left as the default handler set it.
|
|
24
|
+
*
|
|
25
|
+
* Runs for master-key writes too (the triggers here are registered without
|
|
26
|
+
* `skipWithMasterKey`), so re-saving an entry written before this existed is
|
|
27
|
+
* enough to repair its ACL.
|
|
28
|
+
*/
|
|
29
|
+
function allowSubjectUserWrite(object) {
|
|
30
|
+
if (object.entryClassName !== "_User")
|
|
31
|
+
return;
|
|
32
|
+
const subjectId = object.entryObjectId;
|
|
33
|
+
if (!subjectId)
|
|
34
|
+
return;
|
|
35
|
+
const acl = object.getACL() ?? new node_1.default.ACL();
|
|
36
|
+
acl.setReadAccess(subjectId, true);
|
|
37
|
+
acl.setWriteAccess(subjectId, true);
|
|
38
|
+
object.setACL(acl);
|
|
39
|
+
}
|
|
7
40
|
async function init() {
|
|
8
41
|
(0, index_js_2.beforeSaveHook)(index_js_3.Meta_Entry, async (request) => {
|
|
9
42
|
await (0, index_js_2.defaultHandler)(request);
|
|
10
|
-
await (0, index_js_2.defaultAclHandler)(request
|
|
43
|
+
await (0, index_js_2.defaultAclHandler)(request, {
|
|
44
|
+
// Meta entries are collaborative header data: the tenant's users are the
|
|
45
|
+
// ones filling them in from the ticket/equipment UI, so they need write
|
|
46
|
+
// and not just read. Without this a plain tenant user could create an
|
|
47
|
+
// entry and never edit it again — the write came back as Parse's
|
|
48
|
+
// `101 Object not found`.
|
|
49
|
+
//
|
|
50
|
+
// A `_User` entry is the exception. It is one person's own data, and
|
|
51
|
+
// `allowSubjectUserWrite` grants write to exactly that person below, so
|
|
52
|
+
// there is no reason for every colleague in the tenant to have it.
|
|
53
|
+
allowTenantUserWrite: request.object.entryClassName !== "_User",
|
|
54
|
+
});
|
|
55
|
+
allowSubjectUserWrite(request.object);
|
|
11
56
|
});
|
|
12
57
|
(0, index_js_2.afterSaveHook)(index_js_3.Meta_Entry, async ({ object }) => {
|
|
13
58
|
// An entry is the only trace of a context that has no config behind it —
|
|
@@ -57,10 +57,13 @@ async function init() {
|
|
|
57
57
|
if (isNew && !object.get("createdBy") && user)
|
|
58
58
|
object.set("createdBy", user);
|
|
59
59
|
await (0, index_js_1.defaultHandler)(request);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
// No `allowTenantUserWrite`: who may edit a ticket is decided per record by
|
|
61
|
+
// `setTicketACL` below — creator, assigned users, assigned roles — and a
|
|
62
|
+
// blanket write grant for every user in the tenant would make that decision
|
|
63
|
+
// meaningless (it did: `entity.readonly` was never true for a tenant
|
|
64
|
+
// member). Read is still granted tenant-wide, so the list and board keep
|
|
65
|
+
// showing every ticket.
|
|
66
|
+
await (0, index_js_1.defaultAclHandler)(request, { allowCustomACL: true });
|
|
64
67
|
await setTicketACL(object);
|
|
65
68
|
// Assign a sequential ticket number on creation. Done here (backend) rather
|
|
66
69
|
// than in the frontend because every write path — the create form, the
|
|
@@ -192,12 +195,25 @@ async function nextTenantTicketNumber(tenant) {
|
|
|
192
195
|
* pointers, so we iterate the entries (by object id) rather than the array
|
|
193
196
|
* indices. Parse ACL role grants require the role *name*, not its id, so the
|
|
194
197
|
* assigned role pointers are resolved to names first.
|
|
198
|
+
*
|
|
199
|
+
* This is the authority on who may write a ticket, which is why the tenant-user
|
|
200
|
+
* grant is set here explicitly rather than left to `defaultAclHandler`: tickets
|
|
201
|
+
* saved while that handler still granted the whole tenant write carry the old
|
|
202
|
+
* grant in their stored ACL, and `allowCustomACL` keeps a stored ACL rather
|
|
203
|
+
* than rebuilding it. Setting write to `false` repairs such a ticket the next
|
|
204
|
+
* time it is saved instead of letting the stale grant live on.
|
|
195
205
|
*/
|
|
196
206
|
async function setTicketACL(object) {
|
|
197
207
|
const acl = object.getACL() ?? new node_1.default.ACL();
|
|
198
208
|
// deny global read / write
|
|
199
209
|
acl.setPublicReadAccess(false);
|
|
200
210
|
acl.setPublicWriteAccess(false);
|
|
211
|
+
// the tenant reads every ticket, but writes only its own / assigned ones
|
|
212
|
+
const tenantId = object.get("tenant")?.id;
|
|
213
|
+
if (tenantId) {
|
|
214
|
+
acl.setRoleReadAccess(`od-tenant-user-${tenantId}`, true);
|
|
215
|
+
acl.setRoleWriteAccess(`od-tenant-user-${tenantId}`, false);
|
|
216
|
+
}
|
|
201
217
|
// creator read/write
|
|
202
218
|
const creatorId = object.get("createdBy")?.id;
|
|
203
219
|
if (creatorId) {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f4f5f7;padding:32px 12px;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;">
|
|
2
|
+
<tr>
|
|
3
|
+
<td align="center">
|
|
4
|
+
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:8px;border:1px solid #e3e5e8;">
|
|
5
|
+
<tr>
|
|
6
|
+
<td style="padding:32px 40px 8px 40px;">
|
|
7
|
+
<h1 style="margin:0 0 16px 0;font-size:20px;line-height:28px;color:#1a1d21;font-weight:600;">Passwort zurücksetzen</h1>
|
|
8
|
+
<p style="margin:0 0 16px 0;font-size:15px;line-height:23px;color:#40454c;">
|
|
9
|
+
Hallo,<br>
|
|
10
|
+
du hast angefordert, dein Passwort zurückzusetzen. Klicke dazu auf den folgenden Button:
|
|
11
|
+
</p>
|
|
12
|
+
</td>
|
|
13
|
+
</tr>
|
|
14
|
+
<tr>
|
|
15
|
+
<td align="center" style="padding:8px 40px 24px 40px;">
|
|
16
|
+
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
|
17
|
+
<tr>
|
|
18
|
+
<td bgcolor="#2563eb" style="border-radius:6px;">
|
|
19
|
+
<a href="{{ link }}" style="display:inline-block;padding:13px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:6px;">Passwort zurücksetzen</a>
|
|
20
|
+
</td>
|
|
21
|
+
</tr>
|
|
22
|
+
</table>
|
|
23
|
+
</td>
|
|
24
|
+
</tr>
|
|
25
|
+
<tr>
|
|
26
|
+
<td style="padding:0 40px 24px 40px;">
|
|
27
|
+
<p style="margin:0 0 8px 0;font-size:13px;line-height:20px;color:#6b7280;">
|
|
28
|
+
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
|
|
29
|
+
</p>
|
|
30
|
+
<p style="margin:0;font-size:12px;line-height:18px;word-break:break-all;">
|
|
31
|
+
<a href="{{ link }}" style="color:#2563eb;">{{ link }}</a>
|
|
32
|
+
</p>
|
|
33
|
+
</td>
|
|
34
|
+
</tr>
|
|
35
|
+
<tr>
|
|
36
|
+
<td style="padding:0 40px;">
|
|
37
|
+
<p style="margin:0 0 24px 0;font-size:13px;line-height:20px;color:#6b7280;">
|
|
38
|
+
Der Link ist nur einmal verwendbar. Hast du diese Anfrage nicht gestellt, ignoriere diese E-Mail – dein Passwort bleibt unverändert.
|
|
39
|
+
</p>
|
|
40
|
+
<hr style="border:0;border-top:1px solid #e3e5e8;margin:0 0 24px 0;">
|
|
41
|
+
</td>
|
|
42
|
+
</tr>
|
|
43
|
+
<tr>
|
|
44
|
+
<td style="padding:0 40px 32px 40px;">
|
|
45
|
+
<h2 style="margin:0 0 12px 0;font-size:16px;line-height:24px;color:#1a1d21;font-weight:600;">Reset your password</h2>
|
|
46
|
+
<p style="margin:0 0 12px 0;font-size:14px;line-height:22px;color:#40454c;">
|
|
47
|
+
Hello,<br>
|
|
48
|
+
you requested to reset your password. Use the button above, or open this link:
|
|
49
|
+
</p>
|
|
50
|
+
<p style="margin:0 0 12px 0;font-size:12px;line-height:18px;word-break:break-all;">
|
|
51
|
+
<a href="{{ link }}" style="color:#2563eb;">{{ link }}</a>
|
|
52
|
+
</p>
|
|
53
|
+
<p style="margin:0;font-size:13px;line-height:20px;color:#6b7280;">
|
|
54
|
+
The link can only be used once. If you did not request this, simply ignore this email – your password remains unchanged.
|
|
55
|
+
</p>
|
|
56
|
+
</td>
|
|
57
|
+
</tr>
|
|
58
|
+
</table>
|
|
59
|
+
</td>
|
|
60
|
+
</tr>
|
|
61
|
+
</table>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Passwort zurücksetzen / Reset your password
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
PASSWORT ZURÜCKSETZEN
|
|
2
|
+
=====================
|
|
3
|
+
|
|
4
|
+
Hallo,
|
|
5
|
+
|
|
6
|
+
du hast angefordert, dein Passwort zurückzusetzen. Öffne dazu den
|
|
7
|
+
folgenden Link:
|
|
8
|
+
|
|
9
|
+
{{ link | safe }}
|
|
10
|
+
|
|
11
|
+
Der Link ist nur einmal verwendbar. Falls du diese Anfrage nicht
|
|
12
|
+
gestellt hast, kannst du diese E-Mail ignorieren – dein Passwort
|
|
13
|
+
bleibt unverändert.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
RESET YOUR PASSWORD
|
|
17
|
+
===================
|
|
18
|
+
|
|
19
|
+
Hello,
|
|
20
|
+
|
|
21
|
+
you requested to reset your password. Please open the following
|
|
22
|
+
link:
|
|
23
|
+
|
|
24
|
+
{{ link | safe }}
|
|
25
|
+
|
|
26
|
+
The link can only be used once. If you did not request this, simply
|
|
27
|
+
ignore this email – your password remains unchanged.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openinc/parse-server-opendash",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.1",
|
|
4
4
|
"description": "Parse Server Cloud Code for open.INC Stack.",
|
|
5
5
|
"packageManager": "pnpm@11.25.0",
|
|
6
6
|
"keywords": [
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"scripts": {
|
|
52
52
|
"start": "tsc -w",
|
|
53
53
|
"dockerstart": "concurrently \"pnpm run start\" \"cd ./docker-dev && docker-compose watch\"",
|
|
54
|
-
"build": "rimraf ./dist && tsc && node scripts/copyTranslationFiles.js",
|
|
54
|
+
"build": "rimraf ./dist && tsc && node scripts/copyTranslationFiles.js && node scripts/copyEmailTemplates.js",
|
|
55
55
|
"copy:i18n": "node scripts/copyTranslationFiles.js",
|
|
56
56
|
"deploy": "opendash deploy",
|
|
57
57
|
"schema-down": "parse-server-schema down --prefix OD3_ ./schema",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"seed:v1": "node dist/scripts/seed-v1-testdata.js"
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
|
-
"@opendash/ruleset-core": "
|
|
72
|
+
"@opendash/ruleset-core": "nightly",
|
|
73
73
|
"@openinc/parse-server-schema": "^4.1.2",
|
|
74
74
|
"amqplib": "^0.10.9",
|
|
75
75
|
"cron": "^4.4.0",
|