@7365admin1/core 3.47.1-staging.142 → 3.47.1-staging.144
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/.changeset/person-lookup-scope.md +44 -0
- package/.changeset/person-write-scope.md +38 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +83 -13
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +166 -96
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/test/e2e/harness.mjs +23 -1
- package/test/e2e/person-lookup-scope.e2e.test.mjs +265 -0
- package/test/e2e/person-write-scope.e2e.test.mjs +336 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
"@7365admin1/core": minor
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Scope the single-value people lookups to a site the caller can reach.
|
|
6
|
+
|
|
7
|
+
Seven endpoints on `/api/people` carried `requireAuth` and nothing else and were
|
|
8
|
+
not scoped at all:
|
|
9
|
+
|
|
10
|
+
| Route | What it answered |
|
|
11
|
+
|---|---|
|
|
12
|
+
| `GET /nric/:nric` | a `findOne` on NRIC across the **whole platform** |
|
|
13
|
+
| `GET /contact/:contact` | the same, on contact number |
|
|
14
|
+
| `GET /plateNumber/:plateNumber` | every person with that plate, anywhere |
|
|
15
|
+
| `GET /unit/:unit` | every person at that unit id |
|
|
16
|
+
| `GET /user/:userId` | the person record behind any user id |
|
|
17
|
+
| `GET /company` | company names aggregated across every client |
|
|
18
|
+
| `GET /all-nric` | a paged NRIC list at a caller-named site |
|
|
19
|
+
|
|
20
|
+
So any signed-in account could type an NRIC or a car plate and read the matching
|
|
21
|
+
resident of any estate — name, unit, contact number, plates. #1907 scoped the
|
|
22
|
+
paged list (`GET /api/people`); these are the lookups beside it.
|
|
23
|
+
|
|
24
|
+
None of them takes a site — `layer-common usePeople.findPersonByNRIC`,
|
|
25
|
+
`searchCompanyList` and `getPeopleByUnit` pass only the value being looked up —
|
|
26
|
+
so the scope is decided **after** the read, on the record, with the same
|
|
27
|
+
site-reach rule the camera, HID and people-list already use. A record the caller
|
|
28
|
+
cannot reach is left out, which for these endpoints is exactly what "no such
|
|
29
|
+
person" already looked like: `null`, or an empty list. **No client sees a new
|
|
30
|
+
response shape or a new status code.** `entitleSite` is asked once per distinct
|
|
31
|
+
site, not once per record.
|
|
32
|
+
|
|
33
|
+
`/all-nric` requires a site in the query, so that one is checked before the read
|
|
34
|
+
and answers `404` for a site the caller cannot reach — the same answer a site
|
|
35
|
+
that does not exist gives, so ids cannot be probed.
|
|
36
|
+
|
|
37
|
+
`/company` has no site or unit to scope on, so it is narrowed to the caller's
|
|
38
|
+
own organisations — the fallback `getAll` already uses. `person.repo.getCompany`
|
|
39
|
+
gains an optional `orgs` filter; absent means no restriction, which is what
|
|
40
|
+
Seven365 staff get.
|
|
41
|
+
|
|
42
|
+
`GET /user/:userId` also always answers about **your own** record, whatever your
|
|
43
|
+
memberships say: the resident app reads it during sign-up and when resubmitting
|
|
44
|
+
a rejected registration.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
"@7365admin1/core": minor
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Scope the resident and visitor WRITES to a site the caller can actually reach.
|
|
6
|
+
|
|
7
|
+
`PUT /api/people/id/:id` (edit), `PUT /api/people/:id` (delete),
|
|
8
|
+
`PATCH /api/people/:id` (approve / reject / ask to resubmit) and the exported
|
|
9
|
+
`suspendResidentById` identify the person by an id in the URL and carried
|
|
10
|
+
`requireAuth` and nothing more. Any signed-in account on the platform — a
|
|
11
|
+
resident, a cleaner, a guard at a different estate — could rename, delete,
|
|
12
|
+
approve or reject any other client's resident. #1907 closed the read
|
|
13
|
+
(`GET /api/people`); these are the writes behind it.
|
|
14
|
+
|
|
15
|
+
The scope is resolved from the **stored record**, never from the request, and
|
|
16
|
+
that is not a stylistic choice: **not one live caller sends a site.**
|
|
17
|
+
`layer-common usePeople.deleteById` sends no body at all,
|
|
18
|
+
`reviewResidentPerson` sends only `{ status, remarks }`, and both
|
|
19
|
+
`web-app-property-management pages/[org]/[site]/people-mgmt/index.vue:915-917`
|
|
20
|
+
and `components/PeopleFormMgmt.vue:618-620` explicitly `delete payload.site`
|
|
21
|
+
before calling `updateById`. A guard that read the site out of the body would
|
|
22
|
+
have refused every real caller and protected nobody.
|
|
23
|
+
|
|
24
|
+
The rule is the site-reach rule the camera, HID and people-list already use —
|
|
25
|
+
membership of the site, an org-wide membership at the site's owner, or an active
|
|
26
|
+
`customer.sites` engagement — so an agency contracted to an estate keeps
|
|
27
|
+
working. A record carrying an organisation but no site falls back to that
|
|
28
|
+
organisation. Seven365 staff are unchanged.
|
|
29
|
+
|
|
30
|
+
A resident resubmitting their own registration from the mobile app still works:
|
|
31
|
+
`person.service.ts:155-166` writes a `members` row pinned to the site at
|
|
32
|
+
sign-up, before any approval, so a rejected resident still reaches their own
|
|
33
|
+
site. There is an end-to-end case for exactly that.
|
|
34
|
+
|
|
35
|
+
Also: **who approved a resident now comes from the session.** It was read out of
|
|
36
|
+
the `user` cookie the browser sends, falling back to whatever `approvedBy` the
|
|
37
|
+
request body claimed, so an approval could be attributed to anybody. No caller
|
|
38
|
+
sends `approvedBy`, so nothing is lost.
|
package/dist/index.d.ts
CHANGED
|
@@ -4612,7 +4612,7 @@ declare function usePersonRepo(): {
|
|
|
4612
4612
|
type?: ("resident" | "walk-in" | "drop-off" | "contractor" | "delivery" | "pick-up" | "guest" | "tenant")[] | undefined;
|
|
4613
4613
|
unit?: string | undefined;
|
|
4614
4614
|
}, session?: ClientSession) => Promise<TPerson[]>;
|
|
4615
|
-
getCompany: (search?: string) => Promise<any[]>;
|
|
4615
|
+
getCompany: (search?: string, orgs?: string[]) => Promise<any[]>;
|
|
4616
4616
|
getPeopleByPlateNumber: (plateNumber: string) => Promise<TPerson[]>;
|
|
4617
4617
|
getPeopleByNRIC: ({ page, limit, nric, sort, site, }: {
|
|
4618
4618
|
page?: number | undefined;
|
package/dist/index.js
CHANGED
|
@@ -17735,7 +17735,7 @@ function usePersonRepo() {
|
|
|
17735
17735
|
);
|
|
17736
17736
|
}
|
|
17737
17737
|
}
|
|
17738
|
-
async function getCompany(search) {
|
|
17738
|
+
async function getCompany(search, orgs) {
|
|
17739
17739
|
try {
|
|
17740
17740
|
const cacheKey = (0, import_node_server_utils40.makeCacheKey)(site_people_namespace_collection, {
|
|
17741
17741
|
company: search
|
|
@@ -17747,6 +17747,11 @@ function usePersonRepo() {
|
|
|
17747
17747
|
if (search) {
|
|
17748
17748
|
query2.companyName = { $regex: search, $options: "i" };
|
|
17749
17749
|
}
|
|
17750
|
+
if (orgs) {
|
|
17751
|
+
query2.org = {
|
|
17752
|
+
$in: orgs.filter(import_mongodb36.ObjectId.isValid).map((o) => new import_mongodb36.ObjectId(o))
|
|
17753
|
+
};
|
|
17754
|
+
}
|
|
17750
17755
|
let data = [];
|
|
17751
17756
|
data = await collection.aggregate([
|
|
17752
17757
|
{ $match: query2 },
|
|
@@ -50338,7 +50343,8 @@ function usePersonController() {
|
|
|
50338
50343
|
getCompany: _getCompany,
|
|
50339
50344
|
getPeopleByPlateNumber: _getPeopleByPlateNumber,
|
|
50340
50345
|
getPeopleByNRIC: _getPeopleByNRIC,
|
|
50341
|
-
getByUserId: _getByUserId
|
|
50346
|
+
getByUserId: _getByUserId,
|
|
50347
|
+
getById: _getById
|
|
50342
50348
|
} = usePersonRepo();
|
|
50343
50349
|
const {
|
|
50344
50350
|
add: _add,
|
|
@@ -50346,6 +50352,56 @@ function usePersonController() {
|
|
|
50346
50352
|
reviewResidentPerson: _reviewResidentPerson,
|
|
50347
50353
|
suspendResidentById: _suspendResidentById
|
|
50348
50354
|
} = usePersonService();
|
|
50355
|
+
async function entitlePerson(req, _id) {
|
|
50356
|
+
const actor = await resolveInviteActor(callerId(req));
|
|
50357
|
+
if (actor.isSuperAdmin)
|
|
50358
|
+
return actor;
|
|
50359
|
+
const person = await _getById(_id);
|
|
50360
|
+
if (!person)
|
|
50361
|
+
throw new import_node_server_utils139.NotFoundError("Person not found.");
|
|
50362
|
+
const site = person.site?.toString() ?? "";
|
|
50363
|
+
if (site) {
|
|
50364
|
+
await useCameraViewService().entitleSite({ siteId: site, userId: actor.id });
|
|
50365
|
+
return actor;
|
|
50366
|
+
}
|
|
50367
|
+
const org = person.org?.toString() ?? "";
|
|
50368
|
+
if (!org || !actor.orgIds.includes(org)) {
|
|
50369
|
+
throw new import_node_server_utils139.UnauthorizedError("Not authorized.");
|
|
50370
|
+
}
|
|
50371
|
+
return actor;
|
|
50372
|
+
}
|
|
50373
|
+
async function siteReach(req) {
|
|
50374
|
+
const actor = await resolveInviteActor(callerId(req));
|
|
50375
|
+
const reachable = /* @__PURE__ */ new Map();
|
|
50376
|
+
function canReach(person) {
|
|
50377
|
+
if (actor.isSuperAdmin)
|
|
50378
|
+
return Promise.resolve(true);
|
|
50379
|
+
if (!person)
|
|
50380
|
+
return Promise.resolve(false);
|
|
50381
|
+
const site = person.site?.toString() ?? "";
|
|
50382
|
+
if (!site) {
|
|
50383
|
+
const org = person.org?.toString() ?? "";
|
|
50384
|
+
return Promise.resolve(Boolean(org) && actor.orgIds.includes(org));
|
|
50385
|
+
}
|
|
50386
|
+
if (!reachable.has(site)) {
|
|
50387
|
+
reachable.set(
|
|
50388
|
+
site,
|
|
50389
|
+
useCameraViewService().entitleSite({ siteId: site, userId: actor.id }).then(
|
|
50390
|
+
() => true,
|
|
50391
|
+
() => false
|
|
50392
|
+
)
|
|
50393
|
+
);
|
|
50394
|
+
}
|
|
50395
|
+
return reachable.get(site);
|
|
50396
|
+
}
|
|
50397
|
+
async function only(people) {
|
|
50398
|
+
if (actor.isSuperAdmin)
|
|
50399
|
+
return people;
|
|
50400
|
+
const verdicts = await Promise.all(people.map((p) => canReach(p)));
|
|
50401
|
+
return people.filter((_, i) => verdicts[i]);
|
|
50402
|
+
}
|
|
50403
|
+
return { actor, canReach, only };
|
|
50404
|
+
}
|
|
50349
50405
|
async function add(req, res, next) {
|
|
50350
50406
|
const payload = { ...req.body };
|
|
50351
50407
|
const { error } = schemaPerson.validate(payload, {
|
|
@@ -50479,6 +50535,7 @@ function usePersonController() {
|
|
|
50479
50535
|
return;
|
|
50480
50536
|
}
|
|
50481
50537
|
try {
|
|
50538
|
+
await entitlePerson(req, _id);
|
|
50482
50539
|
const result = await _updateById(_id, req.body);
|
|
50483
50540
|
res.status(200).json({ message: result });
|
|
50484
50541
|
return;
|
|
@@ -50498,6 +50555,7 @@ function usePersonController() {
|
|
|
50498
50555
|
return;
|
|
50499
50556
|
}
|
|
50500
50557
|
try {
|
|
50558
|
+
await entitlePerson(req, _id);
|
|
50501
50559
|
await _deleteById(_id);
|
|
50502
50560
|
res.status(200).json({ message: "Successfully deleted visitor guest." });
|
|
50503
50561
|
return;
|
|
@@ -50518,7 +50576,8 @@ function usePersonController() {
|
|
|
50518
50576
|
}
|
|
50519
50577
|
try {
|
|
50520
50578
|
const data = await _getByNRIC(nric);
|
|
50521
|
-
|
|
50579
|
+
const { canReach } = await siteReach(req);
|
|
50580
|
+
res.json(await canReach(data) ? data : null);
|
|
50522
50581
|
return;
|
|
50523
50582
|
} catch (error2) {
|
|
50524
50583
|
import_node_server_utils138.logger.log({ level: "error", message: error2.message });
|
|
@@ -50537,7 +50596,8 @@ function usePersonController() {
|
|
|
50537
50596
|
}
|
|
50538
50597
|
try {
|
|
50539
50598
|
const data = await _getPersonByPhoneNumber(contact);
|
|
50540
|
-
|
|
50599
|
+
const { canReach } = await siteReach(req);
|
|
50600
|
+
res.json(await canReach(data) ? data : null);
|
|
50541
50601
|
return;
|
|
50542
50602
|
} catch (error2) {
|
|
50543
50603
|
import_node_server_utils138.logger.log({ level: "error", message: error2.message });
|
|
@@ -50581,7 +50641,8 @@ function usePersonController() {
|
|
|
50581
50641
|
type,
|
|
50582
50642
|
unit
|
|
50583
50643
|
});
|
|
50584
|
-
|
|
50644
|
+
const { only } = await siteReach(req);
|
|
50645
|
+
res.json(await only(data));
|
|
50585
50646
|
return;
|
|
50586
50647
|
} catch (error) {
|
|
50587
50648
|
import_node_server_utils138.logger.log({ level: "error", message: error.message });
|
|
@@ -50601,7 +50662,11 @@ function usePersonController() {
|
|
|
50601
50662
|
}
|
|
50602
50663
|
const { search } = value;
|
|
50603
50664
|
try {
|
|
50604
|
-
const
|
|
50665
|
+
const { actor } = await siteReach(req);
|
|
50666
|
+
const data = await _getCompany(
|
|
50667
|
+
search,
|
|
50668
|
+
actor.isSuperAdmin ? void 0 : actor.orgIds
|
|
50669
|
+
);
|
|
50605
50670
|
res.status(200).json(data);
|
|
50606
50671
|
return;
|
|
50607
50672
|
} catch (error2) {
|
|
@@ -50621,7 +50686,8 @@ function usePersonController() {
|
|
|
50621
50686
|
}
|
|
50622
50687
|
try {
|
|
50623
50688
|
const data = await _getPeopleByPlateNumber(plateNumber);
|
|
50624
|
-
|
|
50689
|
+
const { only } = await siteReach(req);
|
|
50690
|
+
res.json({ data: await only(data) });
|
|
50625
50691
|
return;
|
|
50626
50692
|
} catch (error2) {
|
|
50627
50693
|
import_node_server_utils138.logger.log({ level: "error", message: error2.message });
|
|
@@ -50661,6 +50727,10 @@ function usePersonController() {
|
|
|
50661
50727
|
}
|
|
50662
50728
|
});
|
|
50663
50729
|
try {
|
|
50730
|
+
const { actor } = await siteReach(req);
|
|
50731
|
+
if (!actor.isSuperAdmin) {
|
|
50732
|
+
await useCameraViewService().entitleSite({ siteId: site, userId: actor.id });
|
|
50733
|
+
}
|
|
50664
50734
|
const data = await _getPeopleByNRIC({
|
|
50665
50735
|
nric,
|
|
50666
50736
|
page,
|
|
@@ -50687,7 +50757,9 @@ function usePersonController() {
|
|
|
50687
50757
|
}
|
|
50688
50758
|
try {
|
|
50689
50759
|
const data = await _getByUserId(userId);
|
|
50690
|
-
|
|
50760
|
+
const { actor, canReach } = await siteReach(req);
|
|
50761
|
+
const isSelf = Boolean(actor.id) && data?.user?.toString() === actor.id;
|
|
50762
|
+
res.json(isSelf || await canReach(data) ? data : null);
|
|
50691
50763
|
return;
|
|
50692
50764
|
} catch (error2) {
|
|
50693
50765
|
import_node_server_utils138.logger.log({ level: "error", message: error2.message });
|
|
@@ -50696,11 +50768,7 @@ function usePersonController() {
|
|
|
50696
50768
|
}
|
|
50697
50769
|
}
|
|
50698
50770
|
async function reviewResidentPerson(req, res, next) {
|
|
50699
|
-
|
|
50700
|
-
(acc, [key, value]) => ({ ...acc, [key]: value }),
|
|
50701
|
-
{}
|
|
50702
|
-
);
|
|
50703
|
-
req.body.approvedBy = cookies?.["user"] ? cookies["user"].toString() : req.body.approvedBy;
|
|
50771
|
+
req.body.approvedBy = callerId(req) || req.body.approvedBy;
|
|
50704
50772
|
const _id = req.params.id;
|
|
50705
50773
|
const payload = { _id, ...req.body };
|
|
50706
50774
|
const schema2 = import_joi68.default.object({
|
|
@@ -50717,6 +50785,7 @@ function usePersonController() {
|
|
|
50717
50785
|
return;
|
|
50718
50786
|
}
|
|
50719
50787
|
try {
|
|
50788
|
+
await entitlePerson(req, _id);
|
|
50720
50789
|
req.body.approvedBy = { id: req.body.approvedBy, name: "" };
|
|
50721
50790
|
const result = await _reviewResidentPerson(_id, req.body);
|
|
50722
50791
|
res.status(200).json({ message: result });
|
|
@@ -50738,6 +50807,7 @@ function usePersonController() {
|
|
|
50738
50807
|
return;
|
|
50739
50808
|
}
|
|
50740
50809
|
try {
|
|
50810
|
+
await entitlePerson(req, _id);
|
|
50741
50811
|
const result = await _suspendResidentById(_id);
|
|
50742
50812
|
res.status(200).json({ message: result });
|
|
50743
50813
|
return;
|