@7365admin1/core 3.68.2 → 3.69.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/index.d.ts +58 -51
- package/dist/index.js +99 -23
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +107 -31
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/test/e2e/client-invite-modules.e2e.test.mjs +164 -0
- package/test/e2e/user-invite-dedupe.e2e.test.mjs +138 -0
package/package.json
CHANGED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Invite Client: Seven365 chooses the new client's modules, and the owner gets
|
|
2
|
+
// the client's OWN owner role (owner, 2026-09-14: "Client Owner Super Admin can
|
|
3
|
+
// only have the full access on what Seven365 Super Admin given to them, same
|
|
4
|
+
// goes to the modules").
|
|
5
|
+
//
|
|
6
|
+
// - `POST /api/auth/invite/user` with no organisation drops the role (it was a
|
|
7
|
+
// shared, org-less template) and, for staff only, stores `modules`.
|
|
8
|
+
// - `POST /api/organizations/onboarding` from that invitation writes the list on
|
|
9
|
+
// the organisation plus the save row that makes the module gate enforce it,
|
|
10
|
+
// and the owner's membership holds the organisation's own seeded owner role.
|
|
11
|
+
//
|
|
12
|
+
// In-process MongoDB, loopback Redis and a loopback mail sink only (harness.mjs).
|
|
13
|
+
//
|
|
14
|
+
// Run with: yarn test:e2e
|
|
15
|
+
|
|
16
|
+
import { after, before, describe, it } from "node:test";
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { ObjectId } from "mongodb";
|
|
20
|
+
|
|
21
|
+
import { startHarness } from "./harness.mjs";
|
|
22
|
+
|
|
23
|
+
const PASSWORD = "ClientModules-Passw0rd!";
|
|
24
|
+
const STAFF = "sevenadmin-cim@e2e.example.com";
|
|
25
|
+
const OUTSIDER = "outsider-cim@e2e.example.com";
|
|
26
|
+
const CLIENT = "client-cim@e2e.example.com";
|
|
27
|
+
const OTHER_NATURE = "othernature-cim@e2e.example.com";
|
|
28
|
+
|
|
29
|
+
const MODULES = ["visitor-mgmt", "members", "roles", "invitations", "sites"];
|
|
30
|
+
|
|
31
|
+
// `module-gate.util moduleListHash`: sorted keys, one per line, sha256.
|
|
32
|
+
const listHash = (keys) => createHash("sha256").update([...keys].sort().join("\n")).digest("hex");
|
|
33
|
+
|
|
34
|
+
describe("Invite Client chooses modules and the owner gets the client's own role", { concurrency: 1 }, () => {
|
|
35
|
+
let h;
|
|
36
|
+
const id = {};
|
|
37
|
+
const sid = {};
|
|
38
|
+
|
|
39
|
+
before(async () => {
|
|
40
|
+
h = await startHarness();
|
|
41
|
+
Object.assign(id, await seed(h));
|
|
42
|
+
for (const email of [STAFF, OUTSIDER, CLIENT, OTHER_NATURE]) sid[email] = await h.login(email, PASSWORD);
|
|
43
|
+
}, { timeout: 300000 });
|
|
44
|
+
|
|
45
|
+
after(async () => {
|
|
46
|
+
if (h) await h.stop();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const invite = (who, body) => h.api("/auth/invite/user", { method: "POST", sid: sid[who], body });
|
|
50
|
+
const inviteOf = (email) =>
|
|
51
|
+
h.db.collection("verifications").findOne({ type: "user-invite", email }, { sort: { createdAt: -1 } });
|
|
52
|
+
|
|
53
|
+
it("a staff invite for a new client stores the modules and no role", async () => {
|
|
54
|
+
const res = await invite(STAFF, {
|
|
55
|
+
email: CLIENT,
|
|
56
|
+
app: ["security_agency"],
|
|
57
|
+
role: id.templateRole.toString(),
|
|
58
|
+
modules: [...MODULES, "visitor-mgmt"],
|
|
59
|
+
});
|
|
60
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
61
|
+
|
|
62
|
+
const row = await inviteOf(CLIENT);
|
|
63
|
+
assert.ok(!row.metadata.role, "the shared template role is not kept");
|
|
64
|
+
assert.deepEqual(row.metadata.modules, MODULES, "stored once each, in order");
|
|
65
|
+
assert.equal(String(row.metadata.invitedBy), id.staffUser.toString());
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("only Seven365 staff may choose modules", async () => {
|
|
69
|
+
const res = await invite(OUTSIDER, { email: "x-cim@e2e.example.com", app: ["security_agency"], modules: MODULES });
|
|
70
|
+
assert.ok([401, 403].includes(res.status), `expected a refusal, got ${res.status}`);
|
|
71
|
+
assert.equal(await inviteOf("x-cim@e2e.example.com"), null, "nothing was written");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("modules are refused for more than one application or an existing organisation", async () => {
|
|
75
|
+
const two = await invite(STAFF, { email: "two-cim@e2e.example.com", app: ["security_agency", "cleaning_services"], modules: MODULES });
|
|
76
|
+
assert.equal(two.status, 400, JSON.stringify(two.body));
|
|
77
|
+
|
|
78
|
+
const existing = await invite(STAFF, {
|
|
79
|
+
email: "existing-cim@e2e.example.com",
|
|
80
|
+
app: ["organization"],
|
|
81
|
+
org: id.org.toString(),
|
|
82
|
+
modules: MODULES,
|
|
83
|
+
});
|
|
84
|
+
assert.equal(existing.status, 400, JSON.stringify(existing.body));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("a Seven365 staff invitation keeps its role", async () => {
|
|
88
|
+
const res = await invite(STAFF, { email: "newstaff-cim@e2e.example.com", app: ["admin"], role: id.staffRole.toString() });
|
|
89
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
90
|
+
assert.equal(String((await inviteOf("newstaff-cim@e2e.example.com")).metadata.role), id.staffRole.toString());
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("onboarding from the invitation enforces the list and gives the owner the client's own owner role", async () => {
|
|
94
|
+
const row = await inviteOf(CLIENT);
|
|
95
|
+
const res = await h.api("/organizations/onboarding", {
|
|
96
|
+
method: "POST",
|
|
97
|
+
sid: sid[CLIENT],
|
|
98
|
+
body: { name: "CIM Security Client", type: "org", nature: "security_agency", email: CLIENT, inviteId: row._id.toString() },
|
|
99
|
+
});
|
|
100
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
101
|
+
|
|
102
|
+
const org = await h.db.collection("organizations").findOne({ name: "CIM Security Client" });
|
|
103
|
+
assert.deepEqual(org.modules, MODULES);
|
|
104
|
+
assert.ok(!org.invitedRole, "no template recorded on the organisation");
|
|
105
|
+
|
|
106
|
+
const save = await h.db
|
|
107
|
+
.collection("console-audit")
|
|
108
|
+
.find({ action: "client.modules-changed", target: org._id.toString().toLowerCase() })
|
|
109
|
+
.toArray();
|
|
110
|
+
assert.equal(save.length, 1, "one save row");
|
|
111
|
+
assert.equal(save[0].after.gateListHash, listHash(MODULES), "the gate's enforcement marker");
|
|
112
|
+
assert.equal(String(save[0].actor), id.staffUser.toString(), "the staff member who chose the list");
|
|
113
|
+
|
|
114
|
+
const member = await h.db.collection("members").findOne({ org: org._id, type: "organization" });
|
|
115
|
+
const role = await h.db.collection("roles").findOne({ _id: member.role });
|
|
116
|
+
assert.equal(String(role.org), org._id.toString(), "the organisation's own role, not a shared template");
|
|
117
|
+
assert.equal(role.name, "owner");
|
|
118
|
+
assert.deepEqual(role.permissions, ["*"]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("an organisation of a different nature gets no list", async () => {
|
|
122
|
+
await invite(STAFF, { email: OTHER_NATURE, app: ["security_agency"], modules: MODULES });
|
|
123
|
+
const row = await inviteOf(OTHER_NATURE);
|
|
124
|
+
|
|
125
|
+
const res = await h.api("/organizations/onboarding", {
|
|
126
|
+
method: "POST",
|
|
127
|
+
sid: sid[OTHER_NATURE],
|
|
128
|
+
body: { name: "CIM Cleaning Client", type: "org", nature: "cleaning_services", email: OTHER_NATURE, inviteId: row._id.toString() },
|
|
129
|
+
});
|
|
130
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
131
|
+
|
|
132
|
+
const org = await h.db.collection("organizations").findOne({ name: "CIM Cleaning Client" });
|
|
133
|
+
assert.ok(!org.modules?.length, "a Security list would hide every Cleaning module");
|
|
134
|
+
assert.equal(
|
|
135
|
+
await h.db.collection("console-audit").countDocuments({ target: org._id.toString().toLowerCase() }),
|
|
136
|
+
0,
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
async function seed(h) {
|
|
142
|
+
const now = new Date().toISOString();
|
|
143
|
+
const org = new ObjectId();
|
|
144
|
+
const staffRole = new ObjectId();
|
|
145
|
+
const templateRole = new ObjectId();
|
|
146
|
+
|
|
147
|
+
await h.db.collection("organizations").insertOne({
|
|
148
|
+
_id: org, name: "CIM Existing Org", email: "existing-org-cim@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now,
|
|
149
|
+
});
|
|
150
|
+
await h.db.collection("roles").insertMany([
|
|
151
|
+
{ _id: staffRole, name: "Super Admin", type: "admin", default: true, permissions: [], status: "active" },
|
|
152
|
+
{ _id: templateRole, name: "Org Owner", type: "organization", permissions: ["*"], status: "active" },
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
const users = await h.db.collection("users").insertMany(
|
|
156
|
+
[STAFF, OUTSIDER, CLIENT, OTHER_NATURE].map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })),
|
|
157
|
+
);
|
|
158
|
+
const hashed = await h.hashPassword(PASSWORD);
|
|
159
|
+
await h.db.collection("users").updateMany({}, { $set: { password: hashed } });
|
|
160
|
+
|
|
161
|
+
await h.db.collection("members").insertOne({ user: users.insertedIds[0], type: "admin", role: staffRole, status: "active" });
|
|
162
|
+
|
|
163
|
+
return { org, staffRole, templateRole, staffUser: users.insertedIds[0] };
|
|
164
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Re-inviting the same person must not add a second pending invitation.
|
|
2
|
+
//
|
|
3
|
+
// `createSimpleUserInvite` (POST /api/auth/invite/user — the console's Invite
|
|
4
|
+
// Client form and the org/hygiene invite screens) looked for an existing
|
|
5
|
+
// invitation and threw the answer away, so every click added another pending
|
|
6
|
+
// row and another e-mail. Now a pending invitation for the same person, app,
|
|
7
|
+
// organisation and site is sent again on the SAME row: fresh expiry, the new
|
|
8
|
+
// request's role. Accepted or cancelled invitations are left alone.
|
|
9
|
+
//
|
|
10
|
+
// In-process MongoDB, loopback Redis and a loopback mail sink only (harness.mjs).
|
|
11
|
+
//
|
|
12
|
+
// Run with: yarn test:e2e
|
|
13
|
+
|
|
14
|
+
import { after, before, describe, it } from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { ObjectId } from "mongodb";
|
|
17
|
+
|
|
18
|
+
import { startHarness } from "./harness.mjs";
|
|
19
|
+
|
|
20
|
+
const PASSWORD = "InviteDedupe-Passw0rd!";
|
|
21
|
+
const STAFF = "sevenadmin-dedupe@e2e.example.com";
|
|
22
|
+
const OWNER = "owner-dedupe@e2e.example.com";
|
|
23
|
+
const CLIENT = "Client-Dedupe@e2e.example.com";
|
|
24
|
+
|
|
25
|
+
describe("re-inviting reuses the pending invitation", { concurrency: 1 }, () => {
|
|
26
|
+
let h;
|
|
27
|
+
const id = {};
|
|
28
|
+
let staffSid;
|
|
29
|
+
let ownerSid;
|
|
30
|
+
|
|
31
|
+
before(async () => {
|
|
32
|
+
h = await startHarness();
|
|
33
|
+
Object.assign(id, await seed(h));
|
|
34
|
+
staffSid = await h.login(STAFF, PASSWORD);
|
|
35
|
+
ownerSid = await h.login(OWNER, PASSWORD);
|
|
36
|
+
}, { timeout: 300000 });
|
|
37
|
+
|
|
38
|
+
after(async () => {
|
|
39
|
+
if (h) await h.stop();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const invites = (email) =>
|
|
43
|
+
h.db
|
|
44
|
+
.collection("verifications")
|
|
45
|
+
.find({ type: "user-invite", email: new RegExp(`^${email}$`, "i") })
|
|
46
|
+
.toArray();
|
|
47
|
+
|
|
48
|
+
const invite = (sid, body) =>
|
|
49
|
+
h.api("/auth/invite/user", { method: "POST", sid, body });
|
|
50
|
+
|
|
51
|
+
it("the console's client invite, sent twice, is one row and the same link", async () => {
|
|
52
|
+
const body = { email: CLIENT, app: ["security_agency"], role: id.templateRole.toString(), name: "Org Owner" };
|
|
53
|
+
|
|
54
|
+
const first = await invite(staffSid, body);
|
|
55
|
+
assert.equal(first.status, 201, JSON.stringify(first.body));
|
|
56
|
+
|
|
57
|
+
const sent = h.mails.length;
|
|
58
|
+
// a different letter case is the same person
|
|
59
|
+
const second = await invite(staffSid, { ...body, email: CLIENT.toLowerCase() });
|
|
60
|
+
assert.equal(second.status, 201, JSON.stringify(second.body));
|
|
61
|
+
|
|
62
|
+
const rows = await invites(CLIENT);
|
|
63
|
+
assert.equal(rows.length, 1, "no second pending invitation");
|
|
64
|
+
assert.deepEqual(second.body.verificationIds, first.body.verificationIds);
|
|
65
|
+
|
|
66
|
+
// the e-mail is sent again, carrying the same link
|
|
67
|
+
const mail = await h.mailAt(sent);
|
|
68
|
+
assert.ok(mail.body.includes(first.body.verificationIds[0]), "resent mail links the same invitation");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("an organisation invite sent again takes the new role", async () => {
|
|
72
|
+
const body = {
|
|
73
|
+
email: "member-dedupe@e2e.example.com",
|
|
74
|
+
app: ["organization"],
|
|
75
|
+
org: id.org.toString(),
|
|
76
|
+
siteId: id.site.toString(),
|
|
77
|
+
siteName: "Dedupe Site",
|
|
78
|
+
role: id.roleA.toString(),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
assert.equal((await invite(ownerSid, body)).status, 201);
|
|
82
|
+
assert.equal((await invite(ownerSid, { ...body, role: id.roleB.toString() })).status, 201);
|
|
83
|
+
|
|
84
|
+
const rows = await invites(body.email);
|
|
85
|
+
assert.equal(rows.length, 1);
|
|
86
|
+
assert.equal(rows[0].metadata.role.toString(), id.roleB.toString());
|
|
87
|
+
assert.equal(rows[0].metadata.org.toString(), id.org.toString(), "still stored as the organisation id");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("a different app, or an invitation already accepted, still creates a new one", async () => {
|
|
91
|
+
const body = { email: "multi-dedupe@e2e.example.com", app: ["security_agency"], role: id.templateRole.toString() };
|
|
92
|
+
|
|
93
|
+
await invite(staffSid, body);
|
|
94
|
+
await invite(staffSid, { ...body, app: ["cleaning_services"] });
|
|
95
|
+
assert.equal((await invites(body.email)).length, 2);
|
|
96
|
+
|
|
97
|
+
await h.db.collection("verifications").updateMany({ email: body.email }, { $set: { status: "complete" } });
|
|
98
|
+
await invite(staffSid, body);
|
|
99
|
+
assert.equal((await invites(body.email)).length, 3);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
async function seed(h) {
|
|
104
|
+
const now = new Date().toISOString();
|
|
105
|
+
const org = new ObjectId();
|
|
106
|
+
const site = new ObjectId();
|
|
107
|
+
const staffRole = new ObjectId();
|
|
108
|
+
const templateRole = new ObjectId();
|
|
109
|
+
const roleA = new ObjectId();
|
|
110
|
+
const roleB = new ObjectId();
|
|
111
|
+
|
|
112
|
+
await h.db.collection("organizations").insertOne({
|
|
113
|
+
_id: org, name: "Dedupe Org", email: OWNER, type: "org", nature: "property_management_agency", status: "active", createdAt: now,
|
|
114
|
+
});
|
|
115
|
+
await h.db.collection("sites").insertOne({ _id: site, name: "Dedupe Site", org, orgId: org, status: "active" });
|
|
116
|
+
await h.db.collection("roles").insertMany([
|
|
117
|
+
{ _id: staffRole, name: "Super Admin", type: "admin", default: true, permissions: [], status: "active" },
|
|
118
|
+
{ _id: templateRole, name: "Org Owner", type: "organization", permissions: ["*"], status: "active" },
|
|
119
|
+
{ _id: roleA, name: "Supervisor", org, permissions: ["*"], status: "active" },
|
|
120
|
+
{ _id: roleB, name: "Manager", org, permissions: ["*"], status: "active" },
|
|
121
|
+
]);
|
|
122
|
+
await h.db.collection("subscriptions").insertOne({
|
|
123
|
+
org, type: "organization", status: "active", maxSeats: 0, paidSeats: 0, currentSeats: 0, createdAt: now,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const users = await h.db.collection("users").insertMany(
|
|
127
|
+
[STAFF, OWNER].map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })),
|
|
128
|
+
);
|
|
129
|
+
const hashed = await h.hashPassword(PASSWORD);
|
|
130
|
+
await h.db.collection("users").updateMany({}, { $set: { password: hashed } });
|
|
131
|
+
|
|
132
|
+
await h.db.collection("members").insertMany([
|
|
133
|
+
{ user: users.insertedIds[0], type: "admin", role: staffRole, status: "active" },
|
|
134
|
+
{ user: users.insertedIds[1], org, type: "organization", role: roleA, status: "active" },
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
return { org, site, templateRole, roleA, roleB };
|
|
138
|
+
}
|