@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.
@@ -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(templateDir, { autoescape: true });
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 emailTemplateDir = emailState.getEmailTemplateDir();
74
+ const emailTemplateDirs = emailState.getEmailTemplateDirs();
75
75
  const fullTemplate = template + "." + type;
76
- if (validateEmailTemplate(emailTemplateDir, fullTemplate)) {
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,4 @@
1
+ /**
2
+ * @param name The function name, derived from the file name.
3
+ */
4
+ export declare function init(name: string): Promise<void>;
@@ -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
+ }
@@ -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
- await (0, index_js_1.defaultAclHandler)(request, {
61
- allowCustomACL: true,
62
- allowTenantUserWrite: true,
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.2.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": "0.1.1-nightly.1",
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",