@7365admin1/core 3.52.1-staging.232 → 3.52.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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @iservice365/core
2
2
 
3
+ ## 3.52.1
4
+
5
+ ### Patch Changes
6
+
7
+ - a90696f: Let a client owner save their organisation details during onboarding.
8
+
9
+ `PUT /api/organizations/:id` — the write behind step 1 of the onboarding wizard —
10
+ was gated on `requireOrgAccess` (staff, or a live member of the organisation).
11
+ The client owner has no `members` row at that point; theirs is created at the
12
+ last step of the wizard. Every new client therefore got 401 on the first step of
13
+ onboarding and could not leave it.
14
+
15
+ The gate is now `requireOrgReach`: staff, a live member, **or** somebody holding
16
+ an invitation naming that organisation — the same rule the roles, members and
17
+ invite endpoints already use for the three pre-membership flows. Nothing is
18
+ widened: a member of another client, and a caller holding only a password-reset
19
+ row, are still refused.
20
+
3
21
  ## 3.52.0
4
22
 
5
23
  ### Minor Changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@7365admin1/core",
3
3
  "license": "MIT",
4
- "version": "3.52.1-staging.232",
4
+ "version": "3.52.1",
5
5
  "author": "7365admin1",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -551,6 +551,14 @@ function buildApp(nsu, core) {
551
551
  invitations.put("/:id/decline", requireAuth, decline);
552
552
  }
553
553
 
554
+ // mirrors iservice365-API-core/src/routes/verification.route.ts
555
+ const verifications = express.Router();
556
+ {
557
+ const { getVerifications, cancelUserInvitation } = core.useVerificationController();
558
+ verifications.get("/", requireAuth, getVerifications);
559
+ verifications.put("/:id/cancel", requireAuth, cancelUserInvitation);
560
+ }
561
+
554
562
  // mirrors iservice365-API-core/src/routes/customer-site.route.ts
555
563
  const customerSites = express.Router();
556
564
  {
@@ -1404,6 +1412,7 @@ function buildApp(nsu, core) {
1404
1412
  app.use("/api/users", users);
1405
1413
  app.use("/api/users/v2", usersV2);
1406
1414
  app.use("/api/service-providers", serviceProviders);
1415
+ app.use("/api/verifications", verifications);
1407
1416
  app.use("/api/customer-sites", customerSites);
1408
1417
  app.use("/api/sites", sites);
1409
1418
  app.use("/api/buildings", buildings);
@@ -0,0 +1,370 @@
1
+ // The whole six-step client onboarding wizard, walked over real HTTP, and
2
+ // judged on WHAT ENDED UP IN THE DATABASE.
3
+ //
4
+ // `organization-update-onboarding.e2e.test.mjs` next door proves step 1 can be
5
+ // submitted at all. This walks the rest, because five of the six steps were
6
+ // never checked against the server and two of them were doing the wrong thing:
7
+ //
8
+ // step 5 "Create Site Admin Role" created an ORGANISATION role - `type:
9
+ // "organization"`, no site - with props byte-identical to step 2's.
10
+ // The wizard's gate for it was "any two organisation roles", which
11
+ // step 2 plus anything already satisfies.
12
+ // step 6 "Invite Site Admin" invited with no site at all, so the site admin
13
+ // was never bound to the site the wizard had just created.
14
+ //
15
+ // The web fix (`web-app-org fix/onboarding-wizard-defects`) sends a site-scoped
16
+ // role and a site-bound invitation. This is the other half of that claim: that
17
+ // the SERVER accepts both, stores them, and hands them back in the two reads
18
+ // the wizard uses to recompute its progress - `GET /roles?org=` (does any role
19
+ // carry a site?) and `GET /verifications?type=member-invite` (does any
20
+ // invitation carry a siteId?). Without those two reads answering, the client's
21
+ // progress model would be honest about nothing.
22
+ //
23
+ // Everything here is created and thrown away by the harness: an in-process
24
+ // MongoDB replica set, a loopback Redis, a loopback mail sink. No staging or
25
+ // production database, Redis, mailbox or endpoint is touched.
26
+ //
27
+ // Run with: yarn test:e2e
28
+
29
+ import { after, before, describe, it } from "node:test";
30
+ import assert from "node:assert/strict";
31
+ import { ObjectId } from "mongodb";
32
+
33
+ import { startHarness } from "./harness.mjs";
34
+
35
+ const PASSWORD = "OnbWalk-Passw0rd!";
36
+ const OWNER = "ow-owner@e2e.example.com"; // invited into the org, no membership yet
37
+ const ORG_ADMIN = "ow-orgadmin@e2e.example.com"; // step 3 invites this address
38
+ const SITE_ADMIN = "ow-siteadmin@e2e.example.com"; // step 6 invites this address
39
+
40
+ const NATURE = "property_management_agency";
41
+
42
+ describe("the six-step onboarding wizard, end to end", { concurrency: 1 }, () => {
43
+ let h;
44
+ const id = {};
45
+ const result = [];
46
+
47
+ const record = (name, fn) =>
48
+ it(name, async () => {
49
+ try {
50
+ await fn();
51
+ result.push(["PASS", name]);
52
+ } catch (error) {
53
+ result.push(["FAIL", name]);
54
+ throw error;
55
+ }
56
+ });
57
+
58
+ before(async () => {
59
+ h = await startHarness();
60
+ Object.assign(id, await seed(h));
61
+ }, { timeout: 300000 });
62
+
63
+ after(async () => {
64
+ if (h) await h.stop();
65
+ console.log("\n--- onboarding wizard walk: per-case result ---");
66
+ for (const [verdict, name] of result) console.log(`${verdict} ${name}`);
67
+ });
68
+
69
+ let sid;
70
+ const created = {};
71
+
72
+ record("0. the owner signs in, holding an invitation and no membership", async () => {
73
+ sid = await h.login(OWNER, PASSWORD);
74
+
75
+ const member = await h.db.collection("members").findOne({ user: id.owner });
76
+ assert.equal(member, null, "the owner has no members row at the start - that is the flow");
77
+ });
78
+
79
+ // ---- step 1 : Set Up Organization ---------------------------------------
80
+
81
+ record("1. step 1 saves the organisation details", async () => {
82
+ const res = await h.api(`/organizations/${id.org}`, {
83
+ method: "PUT",
84
+ sid,
85
+ body: {
86
+ name: "OW JLL Singapore",
87
+ type: "org",
88
+ nature: NATURE,
89
+ email: "ow-jll@e2e.example.com",
90
+ contact: "+65 88888888",
91
+ },
92
+ });
93
+
94
+ assert.equal(res.status, 200, JSON.stringify(res.body));
95
+
96
+ const org = await h.db.collection("organizations").findOne({ _id: id.org });
97
+ assert.equal(org.contact, "+65 88888888");
98
+ });
99
+
100
+ record("1b. and the owner's own membership is written, as the page then does", async () => {
101
+ // `handleStepSuccess` in `getting-started/index.vue` creates this the moment
102
+ // step 1 succeeds. Steps 4 onward depend on it: `POST /customer-sites` is
103
+ // `requireOrgAccess`, i.e. a LIVE MEMBER, not merely an invitation holder.
104
+ const res = await h.api("/members/direct", {
105
+ method: "POST",
106
+ sid,
107
+ body: {
108
+ userId: String(id.owner),
109
+ orgId: String(id.org),
110
+ roleId: String(id.orgRole),
111
+ app: "organization",
112
+ onboardingRequired: true,
113
+ },
114
+ });
115
+
116
+ assert.ok(res.status < 400, JSON.stringify(res.body));
117
+
118
+ const member = await h.db.collection("members").findOne({ user: id.owner });
119
+ assert.ok(member, "the owner is now a member of their own organisation");
120
+ });
121
+
122
+ // ---- step 2 : Create Organization Admin Role ----------------------------
123
+
124
+ record("2. step 2 creates an ORGANISATION role, with no site", async () => {
125
+ const res = await h.api("/roles", {
126
+ method: "POST",
127
+ sid,
128
+ body: {
129
+ name: "OW Org Admin",
130
+ permissions: ["members:see-all-members"],
131
+ type: "organization",
132
+ org: String(id.org),
133
+ site: "",
134
+ platform: "website",
135
+ },
136
+ });
137
+
138
+ assert.equal(res.status, 201, JSON.stringify(res.body));
139
+ // `addRole` returns the inserted id, so `data.role` IS the id.
140
+ created.orgRole = String(res.body.data.role);
141
+
142
+ const role = await h.db.collection("roles").findOne({ _id: new ObjectId(created.orgRole) });
143
+ assert.equal(role.type, "organization");
144
+ assert.ok(!role.site, "an organisation role carries no site");
145
+ });
146
+
147
+ // ---- step 3 : Invite Organization Admin ---------------------------------
148
+
149
+ record("3. step 3 records an invitation into the organisation", async () => {
150
+ const res = await h.api("/auth/invite/member", {
151
+ method: "POST",
152
+ sid,
153
+ body: {
154
+ email: ORG_ADMIN,
155
+ app: "organization",
156
+ role: created.orgRole,
157
+ name: "",
158
+ org: String(id.org),
159
+ },
160
+ });
161
+
162
+ assert.equal(res.status, 201, JSON.stringify(res.body));
163
+
164
+ const invite = await h.db
165
+ .collection("verifications")
166
+ .findOne({ email: ORG_ADMIN, type: "member-invite" });
167
+
168
+ assert.ok(invite, "the invitation is stored");
169
+ assert.ok(!invite.metadata?.siteId, "an organisation invitation carries no site");
170
+ });
171
+
172
+ // ---- step 4 : Create Your First Site ------------------------------------
173
+
174
+ record("4. step 4 creates the site", async () => {
175
+ const res = await h.api("/customer-sites", {
176
+ method: "POST",
177
+ sid,
178
+ body: {
179
+ name: "OW Marina One Residences",
180
+ site: "",
181
+ siteOrg: String(id.org),
182
+ siteOrgName: "OW JLL Singapore",
183
+ org: String(id.org),
184
+ category: "residential",
185
+ },
186
+ });
187
+
188
+ assert.equal(res.status, 201, JSON.stringify(res.body));
189
+
190
+ const row = await h.db
191
+ .collection("customer.sites")
192
+ .findOne({ name: "OW Marina One Residences" });
193
+
194
+ assert.ok(row, "a customer-site row exists");
195
+ assert.ok(row.site, "and it names a site");
196
+ created.siteId = String(row.site);
197
+ created.siteName = row.name;
198
+ });
199
+
200
+ // ---- step 5 : Create Site Admin Role. THE DEFECT. -----------------------
201
+
202
+ record("5. step 5 creates a role that is SCOPED TO THE SITE", async () => {
203
+ const res = await h.api("/roles", {
204
+ method: "POST",
205
+ sid,
206
+ body: {
207
+ name: "OW Site Admin",
208
+ permissions: ["members:see-all-members"],
209
+ // what a site-scoped role is everywhere else in the product: the
210
+ // organisation's line of business, plus the site.
211
+ type: NATURE,
212
+ org: String(id.org),
213
+ site: created.siteId,
214
+ platform: "website",
215
+ },
216
+ });
217
+
218
+ assert.equal(res.status, 201, JSON.stringify(res.body));
219
+ created.siteRole = String(res.body.data.role);
220
+
221
+ const role = await h.db.collection("roles").findOne({ _id: new ObjectId(created.siteRole) });
222
+ assert.equal(role.type, NATURE, "not `organization` - that is step 2's role");
223
+ assert.equal(String(role.site), created.siteId, "the site is stored on the role");
224
+ });
225
+
226
+ record("5b. and it is a DIFFERENT role from step 2's, not a duplicate of it", async () => {
227
+ const orgRole = await h.db.collection("roles").findOne({ _id: new ObjectId(created.orgRole) });
228
+ const siteRole = await h.db.collection("roles").findOne({ _id: new ObjectId(created.siteRole) });
229
+
230
+ assert.notEqual(orgRole.type, siteRole.type);
231
+ assert.equal(Boolean(orgRole.site), false);
232
+ assert.equal(Boolean(siteRole.site), true);
233
+ });
234
+
235
+ // ---- step 6 : Invite Site Admin. THE OTHER HALF. ------------------------
236
+
237
+ record("6. step 6 records an invitation BOUND TO THAT SITE", async () => {
238
+ const res = await h.api("/auth/invite/member", {
239
+ method: "POST",
240
+ sid,
241
+ body: {
242
+ email: SITE_ADMIN,
243
+ app: NATURE,
244
+ role: created.siteRole,
245
+ name: "",
246
+ org: String(id.org),
247
+ siteId: created.siteId,
248
+ siteName: created.siteName,
249
+ },
250
+ });
251
+
252
+ assert.equal(res.status, 201, JSON.stringify(res.body));
253
+
254
+ const invite = await h.db
255
+ .collection("verifications")
256
+ .findOne({ email: SITE_ADMIN, type: "member-invite" });
257
+
258
+ assert.ok(invite, "the invitation is stored");
259
+ assert.equal(String(invite.metadata.siteId), created.siteId, "and it names the site");
260
+ assert.equal(invite.metadata.siteName, created.siteName);
261
+ assert.equal(invite.metadata.app, NATURE, "into the organisation's own line of business");
262
+ assert.equal(String(invite.metadata.role), created.siteRole, "with step 5's SITE role");
263
+ });
264
+
265
+ // ---- the two reads the wizard recomputes its progress from ---------------
266
+
267
+ record("7. `GET /roles?org=` shows which role carries a site", async () => {
268
+ const res = await h.api(`/roles?org=${id.org}&limit=100`, { sid });
269
+
270
+ assert.equal(res.status, 200, JSON.stringify(res.body));
271
+
272
+ const withSite = res.body.items.filter((r) => !!r.site);
273
+ assert.equal(withSite.length, 1, "exactly the one step 5 created");
274
+ assert.equal(String(withSite[0]._id), created.siteRole);
275
+ });
276
+
277
+ record("8. `GET /verifications` shows which invitation carries the site", async () => {
278
+ const res = await h.api(
279
+ `/verifications?type=member-invite&orgId=${id.org}&status=pending&limit=100`,
280
+ { sid },
281
+ );
282
+
283
+ assert.equal(res.status, 200, JSON.stringify(res.body));
284
+
285
+ const bound = res.body.items.filter((v) => !!v.metadata?.siteId);
286
+ assert.equal(bound.length, 1, "exactly the one step 6 created");
287
+ assert.equal(bound[0].email, SITE_ADMIN);
288
+ });
289
+
290
+ // ---- and nothing was widened --------------------------------------------
291
+
292
+ record("9. none of these writes is open to a caller with no session", async () => {
293
+ const role = await h.api("/roles", {
294
+ method: "POST",
295
+ body: {
296
+ name: "no session",
297
+ permissions: [],
298
+ type: NATURE,
299
+ org: String(id.org),
300
+ site: created.siteId,
301
+ },
302
+ });
303
+ assert.equal(role.status, 401, JSON.stringify(role.body));
304
+
305
+ const site = await h.api("/customer-sites", {
306
+ method: "POST",
307
+ body: { name: "no session", site: "", siteOrg: String(id.org), org: String(id.org) },
308
+ });
309
+ assert.equal(site.status, 401, JSON.stringify(site.body));
310
+
311
+ assert.equal(
312
+ await h.db.collection("roles").countDocuments({ name: "no session" }),
313
+ 0,
314
+ "and nothing was written",
315
+ );
316
+ });
317
+ });
318
+
319
+ async function seed(h) {
320
+ const now = new Date().toISOString();
321
+ const org = new ObjectId();
322
+ const orgRole = new ObjectId();
323
+
324
+ await h.db.collection("organizations").insertOne({
325
+ _id: org,
326
+ name: "OW Org",
327
+ email: "ow-org@e2e.example.com",
328
+ type: "org",
329
+ nature: NATURE,
330
+ status: "active",
331
+ contact: "+65 00000000",
332
+ createdAt: now,
333
+ });
334
+
335
+ // `sites.getByName` runs a `$text` search, which needs the index production
336
+ // creates at boot. A fresh in-memory database has none, and without it the
337
+ // site create answers 500.
338
+ await h.db.collection("sites").createIndex({ name: "text" });
339
+
340
+ await h.db.collection("roles").insertOne({
341
+ _id: orgRole,
342
+ name: "Owner",
343
+ org,
344
+ permissions: ["*"],
345
+ status: "active",
346
+ type: "organization",
347
+ });
348
+
349
+ const emails = [OWNER];
350
+ const users = await h.db
351
+ .collection("users")
352
+ .insertMany(emails.map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })));
353
+
354
+ const hashed = await h.hashPassword(PASSWORD);
355
+ await h.db.collection("users").updateMany({ email: { $in: emails } }, { $set: { password: hashed } });
356
+
357
+ const owner = users.insertedIds[0];
358
+
359
+ // The owner's own sign-up invitation - the only relationship they hold when
360
+ // the wizard opens.
361
+ await h.db.collection("verifications").insertOne({
362
+ type: "user-invite",
363
+ email: OWNER,
364
+ status: "complete",
365
+ metadata: { org, role: orgRole, app: "organization" },
366
+ createdAt: now,
367
+ });
368
+
369
+ return { org, orgRole, owner };
370
+ }
@@ -1,17 +0,0 @@
1
- ---
2
- "@7365admin1/core": patch
3
- ---
4
-
5
- Let a client owner save their organisation details during onboarding.
6
-
7
- `PUT /api/organizations/:id` — the write behind step 1 of the onboarding wizard —
8
- was gated on `requireOrgAccess` (staff, or a live member of the organisation).
9
- The client owner has no `members` row at that point; theirs is created at the
10
- last step of the wizard. Every new client therefore got 401 on the first step of
11
- onboarding and could not leave it.
12
-
13
- The gate is now `requireOrgReach`: staff, a live member, **or** somebody holding
14
- an invitation naming that organisation — the same rule the roles, members and
15
- invite endpoints already use for the three pre-membership flows. Nothing is
16
- widened: a member of another client, and a caller holding only a password-reset
17
- row, are still refused.