@7365admin1/core 3.130.0 → 3.131.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 +6 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +113 -16
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +113 -16
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/test/e2e/member-direct-grantor.e2e.test.mjs +137 -0
- package/test/e2e/org-members-people.e2e.test.mjs +137 -0
- package/test/e2e/privilege-escalation.e2e.test.mjs +12 -6
- package/test/role-member-org-scope.test.mjs +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @iservice365/core
|
|
2
2
|
|
|
3
|
+
## 3.131.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- cb3d862: `POST /members/direct` enrolling somebody else now needs the Site access editor rule (organisation owner, cross-client Seven365 staff, or an organisation-wide member with members:assign-member-role; never a site-confined member), only at a site the organisation serves, never an owner role the caller does not hold, and never the owner's own access. Enrolling yourself is unchanged. `GET /members?type=organization&people=1` lists every person of the organisation: organisation-wide rows as before plus one row per site-limited person with `limitedTo` (site and role); without `people` the answer is unchanged.
|
|
8
|
+
|
|
3
9
|
## 3.130.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/dist/index.d.ts
CHANGED
|
@@ -446,7 +446,16 @@ declare function useMemberRepo(): {
|
|
|
446
446
|
getByUserIdType: (user: string | ObjectId, type: string) => Promise<TMember>;
|
|
447
447
|
getByRoleId: (role: string | ObjectId) => Promise<any>;
|
|
448
448
|
getAssignedByRoleId: (role: string | ObjectId) => Promise<TMember[]>;
|
|
449
|
-
getAll: ({ search, page, limit, user, org, siteId, siteIds, type, status, roleState, }: {
|
|
449
|
+
getAll: ({ search, page, limit, user, org, siteId, siteIds, type, status, roleState, people, }: {
|
|
450
|
+
/**
|
|
451
|
+
* The Organization app's Members list (owner report 2026-09-29, Aigle /
|
|
452
|
+
* Kanesan): EVERY person of the organisation, once - the organisation-wide
|
|
453
|
+
* rows as before, plus people whose organisation access was removed and
|
|
454
|
+
* who are now limited to sites (their per-site rows, grouped into one row
|
|
455
|
+
* carrying `limitedTo`). Only with `type=organization`, an `org`, no
|
|
456
|
+
* `siteId` and no `roleState`; residents and Seven365 staff never.
|
|
457
|
+
*/
|
|
458
|
+
people?: boolean | undefined;
|
|
450
459
|
search?: string | undefined;
|
|
451
460
|
page?: number | undefined;
|
|
452
461
|
limit?: number | undefined;
|
package/dist/index.js
CHANGED
|
@@ -4834,6 +4834,63 @@ function useMemberRepo() {
|
|
|
4834
4834
|
throw error;
|
|
4835
4835
|
}
|
|
4836
4836
|
}
|
|
4837
|
+
async function peoplePage(query2, page2, limit, rowStages) {
|
|
4838
|
+
const noSite = { $in: ["", null] };
|
|
4839
|
+
const orgWide = await collection.distinct("user", {
|
|
4840
|
+
org: query2.org,
|
|
4841
|
+
type: query2.type,
|
|
4842
|
+
siteId: noSite,
|
|
4843
|
+
status: { $ne: "deleted" }
|
|
4844
|
+
});
|
|
4845
|
+
const { type: orgType, siteId: pinned, ...rest } = query2;
|
|
4846
|
+
const limitedRow = {
|
|
4847
|
+
siteId: pinned ?? { $nin: ["", null] },
|
|
4848
|
+
user: { $nin: orgWide }
|
|
4849
|
+
};
|
|
4850
|
+
const head = [
|
|
4851
|
+
// `$text` (search) may only sit in the first stage.
|
|
4852
|
+
{ $match: { ...rest, type: { $nin: ["resident", "admin"] } } },
|
|
4853
|
+
{ $match: pinned ? limitedRow : { $or: [{ type: orgType }, limitedRow] } },
|
|
4854
|
+
{ $sort: { _id: -1 } },
|
|
4855
|
+
{
|
|
4856
|
+
$group: {
|
|
4857
|
+
_id: { $ifNull: ["$user", "$_id"] },
|
|
4858
|
+
doc: { $first: "$$ROOT" },
|
|
4859
|
+
orgWide: {
|
|
4860
|
+
$max: { $and: [{ $eq: ["$type", orgType] }, { $in: [{ $ifNull: ["$siteId", ""] }, ["", null]] }] }
|
|
4861
|
+
},
|
|
4862
|
+
limitedTo: { $push: { siteId: "$siteId", siteName: "$siteName", role: "$role" } }
|
|
4863
|
+
}
|
|
4864
|
+
},
|
|
4865
|
+
{
|
|
4866
|
+
$replaceRoot: {
|
|
4867
|
+
newRoot: { $mergeObjects: ["$doc", { limitedTo: { $cond: ["$orgWide", [], "$limitedTo"] } }] }
|
|
4868
|
+
}
|
|
4869
|
+
}
|
|
4870
|
+
];
|
|
4871
|
+
const items = await collection.aggregate([...head, { $sort: { _id: -1 } }, { $skip: page2 * limit }, { $limit: limit }, ...rowStages]).toArray();
|
|
4872
|
+
const [counted] = await collection.aggregate([...head, { $count: "n" }]).toArray();
|
|
4873
|
+
const ids2 = (key) => [...new Set(items.flatMap((i) => (i.limitedTo ?? []).map((l) => l[key]?.toString?.() ?? "")))].filter((id) => import_mongodb14.ObjectId.isValid(id)).map((id) => new import_mongodb14.ObjectId(id));
|
|
4874
|
+
const [sites, roles] = await Promise.all([
|
|
4875
|
+
db2.collection("sites").find({ _id: { $in: ids2("siteId") } }, { projection: { name: 1 } }).toArray(),
|
|
4876
|
+
db2.collection("roles").find({ _id: { $in: ids2("role") } }, { projection: { name: 1 } }).toArray()
|
|
4877
|
+
]);
|
|
4878
|
+
const nameOf = (list) => new Map(list.map((d) => [d._id.toString(), d.name ?? ""]));
|
|
4879
|
+
const siteName = nameOf(sites);
|
|
4880
|
+
const roleName = nameOf(roles);
|
|
4881
|
+
for (const item of items) {
|
|
4882
|
+
const bySite = /* @__PURE__ */ new Map();
|
|
4883
|
+
for (const l of item.limitedTo ?? []) {
|
|
4884
|
+
const site = l.siteId?.toString?.() ?? "";
|
|
4885
|
+
const role = roleName.get(l.role?.toString?.() ?? "") ?? "";
|
|
4886
|
+
const entry = bySite.get(site) ?? { siteId: site, siteName: siteName.get(site) || l.siteName || "", roleName: "" };
|
|
4887
|
+
entry.roleName = [entry.roleName, role].filter(Boolean).join(", ");
|
|
4888
|
+
bySite.set(site, entry);
|
|
4889
|
+
}
|
|
4890
|
+
item.limitedTo = [...bySite.values()].sort((a, b) => a.siteName.localeCompare(b.siteName));
|
|
4891
|
+
}
|
|
4892
|
+
return { items, length: counted?.n ?? 0 };
|
|
4893
|
+
}
|
|
4837
4894
|
async function getAll({
|
|
4838
4895
|
search = "",
|
|
4839
4896
|
page: page2 = 1,
|
|
@@ -4844,7 +4901,8 @@ function useMemberRepo() {
|
|
|
4844
4901
|
siteIds,
|
|
4845
4902
|
type = "",
|
|
4846
4903
|
status = "",
|
|
4847
|
-
roleState = ""
|
|
4904
|
+
roleState = "",
|
|
4905
|
+
people = false
|
|
4848
4906
|
}) {
|
|
4849
4907
|
limit = limit > 0 ? limit : 10;
|
|
4850
4908
|
search = search || "";
|
|
@@ -4903,6 +4961,9 @@ function useMemberRepo() {
|
|
|
4903
4961
|
query2.$or = await otherSiteRoleClauses2(query2.org);
|
|
4904
4962
|
cacheOptions.roleState = roleState;
|
|
4905
4963
|
}
|
|
4964
|
+
const peopleMode = people && type === "organization" && !!query2.org && !siteId && !roleState;
|
|
4965
|
+
if (peopleMode)
|
|
4966
|
+
cacheOptions.people = 1;
|
|
4906
4967
|
const cacheKey = (0, import_node_server_utils15.makeCacheKey)(namespace_collection3, cacheOptions);
|
|
4907
4968
|
const cachedData = await getCache(cacheKey);
|
|
4908
4969
|
if (cachedData) {
|
|
@@ -4910,15 +4971,7 @@ function useMemberRepo() {
|
|
|
4910
4971
|
return cachedData;
|
|
4911
4972
|
}
|
|
4912
4973
|
try {
|
|
4913
|
-
const
|
|
4914
|
-
{ $match: query2 },
|
|
4915
|
-
{ $sort: { _id: -1 } },
|
|
4916
|
-
// `paginate()` only LABELS the range ("11-20 of 42"); it does not
|
|
4917
|
-
// slice. Without this $skip every page returned page 1's rows under
|
|
4918
|
-
// a later page's heading. `page` is already zero-based above, and
|
|
4919
|
-
// this is the same $skip/$limit pair the two getOrgs* reads use.
|
|
4920
|
-
{ $skip: page2 * limit },
|
|
4921
|
-
{ $limit: limit },
|
|
4974
|
+
const rowStages = [
|
|
4922
4975
|
{
|
|
4923
4976
|
$lookup: {
|
|
4924
4977
|
from: "roles",
|
|
@@ -4992,11 +5045,29 @@ function useMemberRepo() {
|
|
|
4992
5045
|
dateInvited: { $ifNull: ["$dateInvited", "$createdAt"] },
|
|
4993
5046
|
siteId: 1,
|
|
4994
5047
|
siteName: 1,
|
|
4995
|
-
type: 1
|
|
5048
|
+
type: 1,
|
|
5049
|
+
limitedTo: 1
|
|
4996
5050
|
}
|
|
4997
5051
|
}
|
|
4998
|
-
]
|
|
4999
|
-
|
|
5052
|
+
];
|
|
5053
|
+
let items;
|
|
5054
|
+
let length;
|
|
5055
|
+
if (peopleMode) {
|
|
5056
|
+
({ items, length } = await peoplePage(query2, page2, limit, rowStages));
|
|
5057
|
+
} else {
|
|
5058
|
+
items = await collection.aggregate([
|
|
5059
|
+
{ $match: query2 },
|
|
5060
|
+
{ $sort: { _id: -1 } },
|
|
5061
|
+
// `paginate()` only LABELS the range ("11-20 of 42"); it does not
|
|
5062
|
+
// slice. Without this $skip every page returned page 1's rows under
|
|
5063
|
+
// a later page's heading. `page` is already zero-based above, and
|
|
5064
|
+
// this is the same $skip/$limit pair the two getOrgs* reads use.
|
|
5065
|
+
{ $skip: page2 * limit },
|
|
5066
|
+
{ $limit: limit },
|
|
5067
|
+
...rowStages
|
|
5068
|
+
]).toArray();
|
|
5069
|
+
length = await collection.countDocuments(query2);
|
|
5070
|
+
}
|
|
5000
5071
|
const data = { ...(0, import_node_server_utils15.paginate)(items, page2, limit, length), total: length };
|
|
5001
5072
|
setCache(cacheKey, data, 15 * 60).then(() => {
|
|
5002
5073
|
import_node_server_utils15.logger.info(`Cache set for key: ${cacheKey}`);
|
|
@@ -24001,6 +24072,29 @@ async function requireNotOwnerUnlessOwner(req, orgId, targetUser) {
|
|
|
24001
24072
|
return;
|
|
24002
24073
|
throw new import_node_server_utils58.UnauthorizedError("Only the organisation's owner can change the owner's access.");
|
|
24003
24074
|
}
|
|
24075
|
+
async function requireMemberGrantor(req, input) {
|
|
24076
|
+
const { orgId, targetUser, roleId } = input;
|
|
24077
|
+
const siteId = input.siteId?.toString() ?? "";
|
|
24078
|
+
const id = await requireSiteAccessEditor(req, orgId);
|
|
24079
|
+
await requireNotOwnerUnlessOwner(req, orgId, targetUser);
|
|
24080
|
+
if (staffMayBypass(await resolveInviteActor(id)))
|
|
24081
|
+
return;
|
|
24082
|
+
if (siteId)
|
|
24083
|
+
await requireSiteServedByOrg(siteId, orgId);
|
|
24084
|
+
if (await hasOrgOwnership(id, orgId))
|
|
24085
|
+
return;
|
|
24086
|
+
const db2 = import_node_server_utils58.useAtlas.getDb();
|
|
24087
|
+
if (!db2 || !import_mongodb54.ObjectId.isValid(roleId))
|
|
24088
|
+
return;
|
|
24089
|
+
const role = await db2.collection("roles").findOne({ _id: new import_mongodb54.ObjectId(roleId) }, { projection: { permissions: 1 } });
|
|
24090
|
+
if (isOwnerRole(role)) {
|
|
24091
|
+
const rows = await db2.collection("members").find({ user: new import_mongodb54.ObjectId(id), org: new import_mongodb54.ObjectId(orgId), status: "active" }, { projection: { role: 1 } }).toArray();
|
|
24092
|
+
const held = rows.map((m) => m.role?.toString?.() ?? "").filter((r) => import_mongodb54.ObjectId.isValid(r)).map((r) => new import_mongodb54.ObjectId(r));
|
|
24093
|
+
const holdsOwner = held.length ? await db2.collection("roles").countDocuments({ _id: { $in: held }, permissions: "*", status: "active" }) : 0;
|
|
24094
|
+
if (!holdsOwner)
|
|
24095
|
+
throw new import_node_server_utils58.UnauthorizedError("Only the organisation's owner can give an owner role.");
|
|
24096
|
+
}
|
|
24097
|
+
}
|
|
24004
24098
|
async function requireSiteReachInOrg(req, siteId, orgId) {
|
|
24005
24099
|
if (await hasOrgOwnership(callerId(req), orgId)) {
|
|
24006
24100
|
await requireSiteServedByOrg(siteId, orgId);
|
|
@@ -29244,7 +29338,9 @@ function useMemberController() {
|
|
|
29244
29338
|
siteId: import_joi29.default.string().hex().optional().allow("", null),
|
|
29245
29339
|
type: import_joi29.default.string().required(),
|
|
29246
29340
|
status: import_joi29.default.string().required(),
|
|
29247
|
-
roleState: import_joi29.default.string().valid("other-site").optional().allow("", null)
|
|
29341
|
+
roleState: import_joi29.default.string().valid("other-site").optional().allow("", null),
|
|
29342
|
+
// Organization app Members: every person, site-limited ones included.
|
|
29343
|
+
people: import_joi29.default.boolean().truthy("1").falsy("0").optional().allow("", null)
|
|
29248
29344
|
});
|
|
29249
29345
|
const query2 = { ...req.query };
|
|
29250
29346
|
const { error } = validation.validate(query2);
|
|
@@ -29299,7 +29395,8 @@ function useMemberController() {
|
|
|
29299
29395
|
siteIds,
|
|
29300
29396
|
type,
|
|
29301
29397
|
status,
|
|
29302
|
-
roleState: req.query.roleState ?? ""
|
|
29398
|
+
roleState: req.query.roleState ?? "",
|
|
29399
|
+
people: ["1", "true"].includes(String(req.query.people ?? ""))
|
|
29303
29400
|
});
|
|
29304
29401
|
res.json(items);
|
|
29305
29402
|
return;
|
|
@@ -29440,7 +29537,7 @@ function useMemberController() {
|
|
|
29440
29537
|
await requireOrgReach(req, orgId);
|
|
29441
29538
|
await requireSelfEnrolRole(req, orgId, roleId);
|
|
29442
29539
|
} else {
|
|
29443
|
-
await
|
|
29540
|
+
await requireMemberGrantor(req, { orgId, targetUser: userId, roleId, siteId });
|
|
29444
29541
|
}
|
|
29445
29542
|
const data = await _createMemberDirect({ userId, orgId, roleId, app, siteId, siteName, onboardingRequired, callerId: callerId(req) });
|
|
29446
29543
|
res.status(201).json(data);
|