@7365admin1/layer-common 4.0.0 → 4.0.2
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 +124 -0
- package/components/CameraForm.vue +2 -5
- package/components/CameraMain.vue +3 -6
- package/components/ClientDetailForm.vue +65 -5
- package/components/ClientMain.vue +201 -59
- package/components/HidAccessLogDashboard.vue +114 -34
- package/components/HidQrCodeConfiguration.vue +1 -1
- package/components/HidReaderManagement.vue +34 -11
- package/components/HidReaderUserRoster.vue +376 -0
- package/components/HidUserEnrollment.vue +7 -1
- package/components/HidUserMapping.vue +583 -0
- package/components/MemberMain.vue +12 -2
- package/components/RolePermissionMain.vue +2 -1
- package/composables/useConsoleTier.ts +73 -0
- package/composables/useHidAmico.ts +30 -6
- package/composables/useHidNavigation.ts +8 -1
- package/composables/useMember.ts +0 -5
- package/composables/usePromoCode.ts +57 -0
- package/package.json +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-user-mapping/index.vue +23 -0
- package/pages/[org]/[site]/access-mgmt/hid-users/index.vue +1 -1
- package/utils/client-subscription.test.ts +95 -0
- package/utils/console-tier.test.ts +87 -0
- package/utils/console-tier.ts +67 -0
- package/utils/data.test.ts +84 -0
- package/utils/data.ts +92 -0
- package/utils/promo-code-form.test.ts +437 -0
- package/utils/promo-code-form.ts +246 -0
|
@@ -80,11 +80,18 @@ type HidVisitorQrData = {
|
|
|
80
80
|
type HidVisitorQrResponse = HidVisitorQrData | { data: HidVisitorQrData };
|
|
81
81
|
|
|
82
82
|
type HidPermissionListResponse = {
|
|
83
|
-
items
|
|
84
|
-
page
|
|
85
|
-
pages
|
|
86
|
-
pageRange
|
|
87
|
-
limit
|
|
83
|
+
items?: THidPermissionCandidate[];
|
|
84
|
+
page?: number;
|
|
85
|
+
pages?: number;
|
|
86
|
+
pageRange?: string;
|
|
87
|
+
limit?: number;
|
|
88
|
+
data?: {
|
|
89
|
+
items?: THidPermissionCandidate[];
|
|
90
|
+
page?: number;
|
|
91
|
+
pages?: number;
|
|
92
|
+
pageRange?: string;
|
|
93
|
+
limit?: number;
|
|
94
|
+
};
|
|
88
95
|
};
|
|
89
96
|
|
|
90
97
|
type HidFacialEnrollmentResult = {
|
|
@@ -94,6 +101,14 @@ type HidFacialEnrollmentResult = {
|
|
|
94
101
|
errors?: Array<{ code?: number; message?: string }>;
|
|
95
102
|
};
|
|
96
103
|
|
|
104
|
+
export type HidFacialSyncResult = {
|
|
105
|
+
readerId: string;
|
|
106
|
+
facialDetected: number;
|
|
107
|
+
syncedCount: number;
|
|
108
|
+
unmappedCount: number;
|
|
109
|
+
message: string;
|
|
110
|
+
};
|
|
111
|
+
|
|
97
112
|
export type HidPhysicalCardType = "pacs" | "csn";
|
|
98
113
|
|
|
99
114
|
export type HidPhysicalCard = {
|
|
@@ -184,6 +199,13 @@ export default function useHidAmico() {
|
|
|
184
199
|
});
|
|
185
200
|
}
|
|
186
201
|
|
|
202
|
+
function syncReaderFacialData(readerId: string) {
|
|
203
|
+
return useNuxtApp().$api<{ data: HidFacialSyncResult }>(
|
|
204
|
+
`${basePath}/readers/${readerId}/facial-sync`,
|
|
205
|
+
{ method: "POST" },
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
187
209
|
function configureReaderIntegration(readerId: string) {
|
|
188
210
|
return useNuxtApp().$api<HidApiRecord>(`${basePath}/readers/${readerId}/configure`, {
|
|
189
211
|
method: "POST",
|
|
@@ -247,6 +269,7 @@ export default function useHidAmico() {
|
|
|
247
269
|
limit?: number;
|
|
248
270
|
search?: string;
|
|
249
271
|
status?: "mapped" | "unmapped" | "";
|
|
272
|
+
includeVisitors?: boolean;
|
|
250
273
|
} = {}) {
|
|
251
274
|
return useNuxtApp().$api<HidCollectionResponse>(`${basePath}/readers/${readerId}/users`, {
|
|
252
275
|
method: "GET",
|
|
@@ -260,7 +283,7 @@ export default function useHidAmico() {
|
|
|
260
283
|
search?: string;
|
|
261
284
|
status?: "authorized" | "not_authorized" | "unknown" | "";
|
|
262
285
|
method?: "facial" | "qr_code" | "id_password" | "pin" | "card" | "";
|
|
263
|
-
tab?: "resident" | "visitor" | "administrator";
|
|
286
|
+
tab?: "all" | "unmapped" | "mapped" | "resident" | "visitor" | "administrator";
|
|
264
287
|
} = {}) {
|
|
265
288
|
return useNuxtApp().$api<HidCollectionResponse>(`${basePath}/readers/${readerId}/access-logs`, {
|
|
266
289
|
method: "GET",
|
|
@@ -492,6 +515,7 @@ export default function useHidAmico() {
|
|
|
492
515
|
deleteReader,
|
|
493
516
|
testReader,
|
|
494
517
|
syncReader,
|
|
518
|
+
syncReaderFacialData,
|
|
495
519
|
configureReaderIntegration,
|
|
496
520
|
setReaderMonitor,
|
|
497
521
|
setReaderOperatingMode,
|
|
@@ -23,12 +23,19 @@ export default function useHidNavigation() {
|
|
|
23
23
|
},
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
|
-
title: "HID Users",
|
|
26
|
+
title: "HID Reader Users",
|
|
27
27
|
route: {
|
|
28
28
|
name: "org-site-access-mgmt-hid-users",
|
|
29
29
|
params: { org, site },
|
|
30
30
|
},
|
|
31
31
|
},
|
|
32
|
+
{
|
|
33
|
+
title: "HID User Mapping",
|
|
34
|
+
route: {
|
|
35
|
+
name: "org-site-access-mgmt-hid-user-mapping",
|
|
36
|
+
params: { org, site },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
32
39
|
{
|
|
33
40
|
title: "HID Cards",
|
|
34
41
|
route: {
|
package/composables/useMember.ts
CHANGED
|
@@ -28,10 +28,6 @@ export default function useMember() {
|
|
|
28
28
|
function getAllByUserId(user: string) {
|
|
29
29
|
return useNuxtApp().$api<TMember>(`/api/members/users/${user}`);
|
|
30
30
|
}
|
|
31
|
-
function getByMemberId(user: string) {
|
|
32
|
-
return useNuxtApp().$api<TMember>(`/api//user/${user}`);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
31
|
function getByUserIdType(user: string, type: string) {
|
|
36
32
|
return useNuxtApp().$api<TMember>(`/api/members/user/${user}/app/${type}`);
|
|
37
33
|
}
|
|
@@ -165,7 +161,6 @@ export default function useMember() {
|
|
|
165
161
|
createUserByVerification,
|
|
166
162
|
createMemberInvite,
|
|
167
163
|
getByUserIdType,
|
|
168
|
-
getByMemberId,
|
|
169
164
|
updateMemberStatus,
|
|
170
165
|
updateMemberRole,
|
|
171
166
|
createMemberDirect,
|
|
@@ -28,9 +28,66 @@ export default function usePromoCode() {
|
|
|
28
28
|
});
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/** One record, by its id. The console's detail screen opens on this. */
|
|
32
|
+
function getById(id: string) {
|
|
33
|
+
return useNuxtApp().$api<Record<string, any>>(
|
|
34
|
+
`/api/promo-codes/id/${id}`,
|
|
35
|
+
{
|
|
36
|
+
method: "GET",
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Edit a code.
|
|
43
|
+
*
|
|
44
|
+
* `code` is NOT sent. It is immutable on the server - a subscription records
|
|
45
|
+
* the promo code it was bought with as text, so renaming one rewrites every
|
|
46
|
+
* invoice that already quotes it. The API accepts a `code` key and ignores
|
|
47
|
+
* it; leaving it out of the body means the screen can never look as though
|
|
48
|
+
* it changed something it did not.
|
|
49
|
+
*/
|
|
50
|
+
function update(id: string, value: Partial<TPromoCode>) {
|
|
51
|
+
const { code, ...body } = value as Record<string, any>;
|
|
52
|
+
|
|
53
|
+
return useNuxtApp().$api<Record<string, any>>(`/api/promo-codes/${id}`, {
|
|
54
|
+
method: "PUT",
|
|
55
|
+
body,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Turn a code off, or back on. The only two states staff may set. */
|
|
60
|
+
function updateStatus(id: string, status: "active" | "disabled") {
|
|
61
|
+
return useNuxtApp().$api<Record<string, any>>(
|
|
62
|
+
`/api/promo-codes/${id}/status`,
|
|
63
|
+
{
|
|
64
|
+
method: "PATCH",
|
|
65
|
+
body: { status },
|
|
66
|
+
}
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Remove a code from the console.
|
|
72
|
+
*
|
|
73
|
+
* A SOFT delete on the server: the record stays and is marked disabled and
|
|
74
|
+
* deleted, because subscriptions and invoices quote the code they were
|
|
75
|
+
* bought with. It leaves the list and stops being redeemable; it is not
|
|
76
|
+
* erased.
|
|
77
|
+
*/
|
|
78
|
+
function remove(id: string) {
|
|
79
|
+
return useNuxtApp().$api<Record<string, any>>(`/api/promo-codes/${id}`, {
|
|
80
|
+
method: "DELETE",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
31
84
|
return {
|
|
32
85
|
add,
|
|
33
86
|
getPromoCodes,
|
|
34
87
|
getByCode,
|
|
88
|
+
getById,
|
|
89
|
+
update,
|
|
90
|
+
updateStatus,
|
|
91
|
+
remove,
|
|
35
92
|
};
|
|
36
93
|
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "4.0.
|
|
5
|
+
"version": "4.0.2",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-container fluid>
|
|
3
|
+
<HidEnabledGate
|
|
4
|
+
:site="siteId"
|
|
5
|
+
:org="orgId"
|
|
6
|
+
message="Enable HID as a service for this site before mapping HID users."
|
|
7
|
+
>
|
|
8
|
+
<HidUserMapping :site="siteId" />
|
|
9
|
+
</HidEnabledGate>
|
|
10
|
+
</v-container>
|
|
11
|
+
</template>
|
|
12
|
+
|
|
13
|
+
<script setup lang="ts">
|
|
14
|
+
definePageMeta({
|
|
15
|
+
layout: "default",
|
|
16
|
+
middleware: ["01-auth", "02-org"],
|
|
17
|
+
memberOnly: true,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const route = useRoute();
|
|
21
|
+
const siteId = computed(() => String(route.params.site ?? ""));
|
|
22
|
+
const orgId = computed(() => String(route.params.org ?? ""));
|
|
23
|
+
</script>
|
|
@@ -143,3 +143,98 @@ test("a billing cycle the screen does not know is shown, not swallowed", () => {
|
|
|
143
143
|
"Yearly",
|
|
144
144
|
);
|
|
145
145
|
});
|
|
146
|
+
|
|
147
|
+
/* ── SUSPEND / REACTIVATE, THE STATE THE LIST HAS TO SHOW AFTERWARDS ──────
|
|
148
|
+
*
|
|
149
|
+
* `PATCH /api/organizations/:id/status` (core `organization.controller.ts`
|
|
150
|
+
* `updateStatus`) writes BOTH `organizations.status` and the organisation's
|
|
151
|
+
* subscription status - the second one so the hourly sync job agrees rather
|
|
152
|
+
* than undoing the decision an hour later. So after the list is re-read, a
|
|
153
|
+
* suspended client arrives with both set, and this is what the row then says.
|
|
154
|
+
* Nothing in the screen holds a second copy of that state.
|
|
155
|
+
*/
|
|
156
|
+
|
|
157
|
+
/** Exactly what `updateStatus` leaves behind, applied to a list row. */
|
|
158
|
+
function afterStatusChange(org: Record<string, any>, status: "active" | "suspended") {
|
|
159
|
+
return {
|
|
160
|
+
...org,
|
|
161
|
+
status,
|
|
162
|
+
subscription: org.subscription?._id
|
|
163
|
+
? { ...org.subscription, status }
|
|
164
|
+
: org.subscription,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
test("suspending a client makes the row say Suspended", () => {
|
|
169
|
+
const before = orgWithSub({});
|
|
170
|
+
assert.equal(describeClientSubscription(before, NOW).state, "active");
|
|
171
|
+
|
|
172
|
+
const after = afterStatusChange(before, "suspended");
|
|
173
|
+
const v = describeClientSubscription(after, NOW);
|
|
174
|
+
|
|
175
|
+
assert.equal(v.state, "suspended");
|
|
176
|
+
assert.equal(v.label, "Suspended");
|
|
177
|
+
// Suspension is a decision somebody took, not a thing needing attention.
|
|
178
|
+
assert.equal(v.needsAttention, false);
|
|
179
|
+
// The dates are untouched - "data is kept" is visible, not just claimed.
|
|
180
|
+
assert.equal(v.billingCycle, describeClientSubscription(before, NOW).billingCycle);
|
|
181
|
+
assert.equal(v.start, describeClientSubscription(before, NOW).start);
|
|
182
|
+
assert.equal(v.end, describeClientSubscription(before, NOW).end);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("reactivating puts the row back exactly where it was", () => {
|
|
186
|
+
const before = orgWithSub({});
|
|
187
|
+
const round = afterStatusChange(afterStatusChange(before, "suspended"), "active");
|
|
188
|
+
|
|
189
|
+
assert.deepEqual(
|
|
190
|
+
describeClientSubscription(round, NOW),
|
|
191
|
+
describeClientSubscription(before, NOW),
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("a complimentary client suspends and reactivates the same way", () => {
|
|
196
|
+
// All 11 production clients are complimentary, so this is the case that
|
|
197
|
+
// actually happens - and `billingMode` must survive the round trip.
|
|
198
|
+
const before = orgWithSub({ billingMode: "complimentary" });
|
|
199
|
+
assert.equal(describeClientSubscription(before, NOW).state, "complimentary");
|
|
200
|
+
|
|
201
|
+
assert.equal(
|
|
202
|
+
describeClientSubscription(afterStatusChange(before, "suspended"), NOW).state,
|
|
203
|
+
"suspended",
|
|
204
|
+
);
|
|
205
|
+
assert.equal(
|
|
206
|
+
describeClientSubscription(afterStatusChange(before, "active"), NOW).state,
|
|
207
|
+
"complimentary",
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("a client with NO subscription document still suspends", () => {
|
|
212
|
+
// `updateStatus` writes the organisation's status either way and only
|
|
213
|
+
// touches a subscription if one exists. This row has none, so the
|
|
214
|
+
// subscription column keeps saying so - the ORGANISATION's status is what
|
|
215
|
+
// moved it to the Suspended tab, and that is the honest reading of the
|
|
216
|
+
// record. The list is fetched per tab, so the row is on the tab that matches.
|
|
217
|
+
const before = { _id: "o-1", name: "A Client", status: "active", subscription: {} };
|
|
218
|
+
const after = afterStatusChange(before, "suspended");
|
|
219
|
+
|
|
220
|
+
assert.equal(after.status, "suspended");
|
|
221
|
+
assert.equal(describeClientSubscription(after, NOW).state, "none");
|
|
222
|
+
assert.equal(describeClientSubscription(after, NOW).label, "No subscription set up");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("suspending does not clear an end date that had already passed", () => {
|
|
226
|
+
// Owner decision 8's flag and a suspension are different things, and the
|
|
227
|
+
// flag is derived at read time - suspending must not hide the fact that the
|
|
228
|
+
// subscription had run out, because reactivating brings it straight back.
|
|
229
|
+
const overdue = orgWithSub({ nextBillingDate: "2026-01-01T00:00:00.000Z" });
|
|
230
|
+
assert.equal(describeClientSubscription(overdue, NOW).needsAttention, true);
|
|
231
|
+
|
|
232
|
+
assert.equal(
|
|
233
|
+
describeClientSubscription(afterStatusChange(overdue, "suspended"), NOW).state,
|
|
234
|
+
"suspended",
|
|
235
|
+
);
|
|
236
|
+
assert.equal(
|
|
237
|
+
describeClientSubscription(afterStatusChange(overdue, "active"), NOW).needsAttention,
|
|
238
|
+
true,
|
|
239
|
+
);
|
|
240
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { consoleTier } from "./console-tier.ts";
|
|
5
|
+
|
|
6
|
+
/** The seeded platform-staff role - `user.service.ts createDefaultUser()`. */
|
|
7
|
+
const OWNER_ROLE = {
|
|
8
|
+
_id: "r-owner",
|
|
9
|
+
name: "Super Admin",
|
|
10
|
+
type: "admin",
|
|
11
|
+
default: true,
|
|
12
|
+
permissions: [],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** A role made through the admin app. `default` is not in its Joi schema. */
|
|
16
|
+
const STAFF_ROLE = {
|
|
17
|
+
_id: "r-staff",
|
|
18
|
+
name: "Operations",
|
|
19
|
+
type: "admin",
|
|
20
|
+
permissions: ["organization:read"],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const ADMIN_MEMBER = { _id: "m-1", user: "u-1", type: "admin", role: "r-owner" };
|
|
24
|
+
|
|
25
|
+
test("the owner is an admin member on an admin role marked default", () => {
|
|
26
|
+
assert.equal(consoleTier(ADMIN_MEMBER, OWNER_ROLE), "owner");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("ordinary Seven365 staff are admin, but their role is not the default one", () => {
|
|
30
|
+
assert.equal(consoleTier(ADMIN_MEMBER, STAFF_ROLE), "staff");
|
|
31
|
+
// The absence of the field, not just `false`, is the ordinary case: the admin
|
|
32
|
+
// app's create-role form cannot send it at all.
|
|
33
|
+
assert.equal(consoleTier(ADMIN_MEMBER, { ...STAFF_ROLE, default: false }), "staff");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("BOTH halves are required - a role name proves nothing", () => {
|
|
37
|
+
// Staging carries an ordinary ORGANISATION role merely NAMED "Super Admin",
|
|
38
|
+
// and `web-app-org/pages/index.vue` gated the whole console on that name.
|
|
39
|
+
// That row must not reach owner, or staff, on the strength of its name.
|
|
40
|
+
const impostor = { _id: "r-x", name: "Super Admin", type: "organization", default: true };
|
|
41
|
+
assert.equal(consoleTier(ADMIN_MEMBER, impostor), "none");
|
|
42
|
+
|
|
43
|
+
// ...and an admin-typed role held through a non-admin membership is not
|
|
44
|
+
// staff either. `isSuperAdmin` requires `members.type === "admin"` too.
|
|
45
|
+
assert.equal(consoleTier({ ...ADMIN_MEMBER, type: "organization" }, OWNER_ROLE), "none");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("`default` is only honoured when it is exactly true", () => {
|
|
49
|
+
// A truthy-but-not-true value is not what `createDefaultUser` writes, and the
|
|
50
|
+
// server compares with `===`. Drawing an owner control off `"true"` or `1`
|
|
51
|
+
// would show a button the server then refuses.
|
|
52
|
+
for (const value of ["true", 1, {}, "yes"]) {
|
|
53
|
+
assert.equal(
|
|
54
|
+
consoleTier(ADMIN_MEMBER, { ...STAFF_ROLE, default: value }),
|
|
55
|
+
"staff",
|
|
56
|
+
JSON.stringify(value),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("anything unproven is `none` - this fails closed", () => {
|
|
62
|
+
const unproven: Array<[any, any]> = [
|
|
63
|
+
[null, OWNER_ROLE],
|
|
64
|
+
[undefined, OWNER_ROLE],
|
|
65
|
+
[{}, OWNER_ROLE],
|
|
66
|
+
[ADMIN_MEMBER, null], // the role request failed
|
|
67
|
+
[ADMIN_MEMBER, undefined],
|
|
68
|
+
[ADMIN_MEMBER, {}],
|
|
69
|
+
[null, null],
|
|
70
|
+
// An error body answered instead of a record. `member.controller.ts`
|
|
71
|
+
// answers `NotFoundError` as JSON, so this is a real wire shape.
|
|
72
|
+
[{ status: "error", message: "Member not found." }, OWNER_ROLE],
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
for (const [member, role] of unproven) {
|
|
76
|
+
assert.equal(consoleTier(member, role), "none", JSON.stringify([member, role]));
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a deleted staff membership is not staff", () => {
|
|
81
|
+
// `isSuperAdmin` excludes it server-side; the endpoint the browser reads does
|
|
82
|
+
// not, so it is excluded here to keep the two answers the same.
|
|
83
|
+
assert.equal(
|
|
84
|
+
consoleTier({ ...ADMIN_MEMBER, status: "deleted" }, OWNER_ROLE),
|
|
85
|
+
"none",
|
|
86
|
+
);
|
|
87
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH SEVEN365 TIER THE SIGNED-IN PERSON IS, MIRRORED FROM THE SERVER'S RULE.
|
|
3
|
+
*
|
|
4
|
+
* Owner decision 9 splits Seven365 staff powers in two. Ordinary staff may view
|
|
5
|
+
* clients, set up subscriptions and manage promo codes. **Only the owner may
|
|
6
|
+
* suspend a client** - an action that stops that client's staff, residents and
|
|
7
|
+
* guards signing in.
|
|
8
|
+
*
|
|
9
|
+
* ## The server is the authority. This file only decides what to DRAW.
|
|
10
|
+
*
|
|
11
|
+
* `iservice365-core` `src/utils/super-admin.util.ts` answers the same question
|
|
12
|
+
* on the server, from the session id alone:
|
|
13
|
+
*
|
|
14
|
+
* isSuperAdmin(userId) -> a `members` row `{ type: "admin" }` whose role
|
|
15
|
+
* document is also `type: "admin"`
|
|
16
|
+
* isPlatformOwner(userId) -> the same, AND that role has `default === true`
|
|
17
|
+
*
|
|
18
|
+
* `requirePlatformOwner` (`console-authz.util.ts`) runs `isPlatformOwner`
|
|
19
|
+
* inside `PATCH /api/organizations/:id/status` before it writes anything, and
|
|
20
|
+
* it reads the session - never the request body, never a header the browser
|
|
21
|
+
* chose. So a browser that lies to itself about its tier gets a button it can
|
|
22
|
+
* press and a 401 when it does. Hiding the control is a courtesy, not a lock.
|
|
23
|
+
*
|
|
24
|
+
* ## Why `role.default`, and why nothing here invents a new marker
|
|
25
|
+
*
|
|
26
|
+
* `default` is the only property of a platform-staff role that no API caller
|
|
27
|
+
* can set: `role.controller.ts` validates create/update with Joi object schemas
|
|
28
|
+
* that do not list it (Joi rejects unknown keys), `role.repo.ts` never writes
|
|
29
|
+
* it, and `MRole` defaults it to `false`. Exactly one server path sets it on an
|
|
30
|
+
* `admin`-typed role - `user.service.ts createDefaultUser()` at API boot. Every
|
|
31
|
+
* additional staff account is invited onto a role made through the admin app,
|
|
32
|
+
* which cannot carry it.
|
|
33
|
+
*
|
|
34
|
+
* ## Fail closed
|
|
35
|
+
*
|
|
36
|
+
* Anything this function cannot positively prove is `"none"`. A missing member
|
|
37
|
+
* row, a role that failed to load, a request that threw - all of them draw the
|
|
38
|
+
* ordinary-staff console with no suspend control, which is the recoverable
|
|
39
|
+
* mistake. The other direction is not.
|
|
40
|
+
*
|
|
41
|
+
* Both fields come back from endpoints the app already calls, unprojected:
|
|
42
|
+
* `GET /api/members/user/:id/app/admin` and `GET /api/roles/id/:id`. Nothing
|
|
43
|
+
* new had to be added to the API for this.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
export type TConsoleTier = "owner" | "staff" | "none";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One divergence from the server, stated rather than hidden: `isSuperAdmin`
|
|
50
|
+
* matches `status: { $ne: "deleted" }` on the member row, and the endpoint the
|
|
51
|
+
* browser uses (`member.repo.ts getByUserIdType`) matches only `{ user, type }`.
|
|
52
|
+
* So a deleted staff membership can still be handed to this function. It is
|
|
53
|
+
* excluded here too, which keeps the drawn console and the server's answer the
|
|
54
|
+
* same for that case.
|
|
55
|
+
*/
|
|
56
|
+
export function consoleTier(
|
|
57
|
+
member: Record<string, any> | null | undefined,
|
|
58
|
+
role: Record<string, any> | null | undefined,
|
|
59
|
+
): TConsoleTier {
|
|
60
|
+
if (member?.type !== "admin") return "none";
|
|
61
|
+
if (member?.status === "deleted") return "none";
|
|
62
|
+
if (role?.type !== "admin") return "none";
|
|
63
|
+
|
|
64
|
+
// `=== true` exactly as the server writes it. A role document that omits
|
|
65
|
+
// `default` (every role the admin app can create) is staff, not owner.
|
|
66
|
+
return role?.default === true ? "owner" : "staff";
|
|
67
|
+
}
|
package/utils/data.test.ts
CHANGED
|
@@ -93,3 +93,87 @@ test("a non-array levels value is counted as zero, not as its own length", () =>
|
|
|
93
93
|
assert.equal(levelCount({ levels: 7 as unknown as [] }), 0);
|
|
94
94
|
assert.equal(levelCount({ levels: "12" as unknown as [] }), 0);
|
|
95
95
|
});
|
|
96
|
+
|
|
97
|
+
import { cameraErrorConverter } from "./data.ts";
|
|
98
|
+
|
|
99
|
+
/** Shape of an ofetch failure: the status and body live under `response`. */
|
|
100
|
+
const apiError = (status: number, message?: string) => ({
|
|
101
|
+
response: { status, _data: message === undefined ? {} : { message } },
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("a duplicate reads as a duplicate", () => {
|
|
105
|
+
assert.match(
|
|
106
|
+
cameraErrorConverter(apiError(400, "ANPR already exist."), "ip"),
|
|
107
|
+
/already exists on this site/
|
|
108
|
+
);
|
|
109
|
+
assert.match(
|
|
110
|
+
cameraErrorConverter(apiError(409), "ip"),
|
|
111
|
+
/already exists on this site/
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a failure that is NOT a duplicate never says duplicate", () => {
|
|
116
|
+
const notDuplicate = [
|
|
117
|
+
cameraErrorConverter(apiError(401), "ip"),
|
|
118
|
+
cameraErrorConverter(apiError(403), "ip"),
|
|
119
|
+
cameraErrorConverter(apiError(404), "ip"),
|
|
120
|
+
cameraErrorConverter(apiError(429), "ip"),
|
|
121
|
+
cameraErrorConverter(apiError(500, "Failed to create ANPR."), "ip"),
|
|
122
|
+
cameraErrorConverter(new Error("Network request failed"), "ip"),
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
for (const message of notDuplicate) {
|
|
126
|
+
assert.doesNotMatch(message, /already exist/i, message);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("each cause gets its own message", () => {
|
|
131
|
+
assert.match(cameraErrorConverter(apiError(401), "ip"), /session has expired/);
|
|
132
|
+
assert.match(
|
|
133
|
+
cameraErrorConverter(apiError(403), "ip"),
|
|
134
|
+
/do not have permission/
|
|
135
|
+
);
|
|
136
|
+
assert.match(cameraErrorConverter(apiError(404), "ip"), /no longer exists/);
|
|
137
|
+
assert.match(cameraErrorConverter(apiError(429), "ip"), /Too many attempts/);
|
|
138
|
+
assert.match(
|
|
139
|
+
cameraErrorConverter(apiError(500, "Failed to create ANPR."), "ip"),
|
|
140
|
+
/could not save this CCTV camera/
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("no response at all reads as a connection problem, not a server refusal", () => {
|
|
145
|
+
assert.match(
|
|
146
|
+
cameraErrorConverter(new Error("Failed to fetch"), "ip"),
|
|
147
|
+
/Could not reach the server/
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("a rejected field is named the way the form names it", () => {
|
|
152
|
+
assert.equal(
|
|
153
|
+
cameraErrorConverter(apiError(400, '"host" is required'), "ip"),
|
|
154
|
+
"URL is required."
|
|
155
|
+
);
|
|
156
|
+
assert.equal(
|
|
157
|
+
cameraErrorConverter(apiError(400, '"name" is not allowed to be empty'), "ip"),
|
|
158
|
+
"Camera Name cannot be empty."
|
|
159
|
+
);
|
|
160
|
+
// A field with no friendly label still reads as a sentence.
|
|
161
|
+
assert.equal(
|
|
162
|
+
cameraErrorConverter(apiError(400, '"guardPost" must be a number'), "ip"),
|
|
163
|
+
"guardPost must be a number."
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("a plain 400 sentence from the API is passed through unchanged", () => {
|
|
168
|
+
assert.equal(
|
|
169
|
+
cameraErrorConverter(apiError(400, "Invalid _id format"), "ip"),
|
|
170
|
+
"Invalid _id format"
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("the camera type decides the wording", () => {
|
|
175
|
+
assert.match(cameraErrorConverter(apiError(404), "anpr"), /ANPR camera/);
|
|
176
|
+
assert.match(cameraErrorConverter(apiError(404), "ip"), /CCTV camera/);
|
|
177
|
+
// Unknown/absent type falls back to CCTV, which is what the panel defaults to.
|
|
178
|
+
assert.match(cameraErrorConverter(apiError(404)), /CCTV camera/);
|
|
179
|
+
});
|
package/utils/data.ts
CHANGED
|
@@ -30,6 +30,98 @@ export const errorConverter = (data: any): string => {
|
|
|
30
30
|
return error;
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Field names the camera API validates, in the wording the camera form uses,
|
|
35
|
+
* so a rejection reads as the label the person is looking at.
|
|
36
|
+
*/
|
|
37
|
+
const CAMERA_FIELD_LABELS: Record<string, string> = {
|
|
38
|
+
host: "URL",
|
|
39
|
+
name: "Camera Name",
|
|
40
|
+
username: "User",
|
|
41
|
+
password: "Password",
|
|
42
|
+
direction: "Type",
|
|
43
|
+
category: "Category",
|
|
44
|
+
site: "Site",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Joi phrasing -> everyday phrasing. Anything unmapped is passed through. */
|
|
48
|
+
const CAMERA_VALIDATION_PHRASES: Array<[RegExp, string]> = [
|
|
49
|
+
[/^is required$/, "is required."],
|
|
50
|
+
[/^is not allowed to be empty$/, "cannot be empty."],
|
|
51
|
+
[/^must be a string$/, "is not valid."],
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Turns a save/delete failure on a site camera into a sentence the person
|
|
56
|
+
* setting up the camera can act on.
|
|
57
|
+
*
|
|
58
|
+
* The camera panel used to report every `type: "ip"` failure as "CCTV camera
|
|
59
|
+
* already exist", whatever actually went wrong - a signed-out session, a
|
|
60
|
+
* missing permission, a rejected field and a server outage all read as a
|
|
61
|
+
* duplicate. This maps the cases the API really returns instead.
|
|
62
|
+
*/
|
|
63
|
+
export const cameraErrorConverter = (error: any, type?: string): string => {
|
|
64
|
+
const camera = type === "anpr" ? "ANPR camera" : "CCTV camera";
|
|
65
|
+
|
|
66
|
+
const status =
|
|
67
|
+
error?.response?.status ?? error?.statusCode ?? error?.status ?? null;
|
|
68
|
+
|
|
69
|
+
const serverMessage = String(
|
|
70
|
+
error?.response?._data?.message ?? error?.data?.message ?? ""
|
|
71
|
+
).trim();
|
|
72
|
+
|
|
73
|
+
// No response at all: the request never reached the API.
|
|
74
|
+
if (!status) {
|
|
75
|
+
return `Could not reach the server, so this ${camera} was not saved. Check your internet connection and try again.`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (status === 401) {
|
|
79
|
+
return `Your session has expired. Sign in again, then save this ${camera}.`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (status === 403) {
|
|
83
|
+
return `You do not have permission to change cameras on this site. Ask your iService365 administrator for access.`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (status === 404) {
|
|
87
|
+
return `This ${camera} no longer exists. Refresh the list and try again.`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (status === 429) {
|
|
91
|
+
return "Too many attempts in a short time. Wait a moment and try again.";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The API reports a clash from the unique index as "ANPR already exist.",
|
|
95
|
+
// which is the same message for a CCTV record.
|
|
96
|
+
if (status === 409 || /already exist|duplicate/i.test(serverMessage)) {
|
|
97
|
+
return `A ${camera} with this URL already exists on this site. Check the list before adding it again.`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (status >= 500) {
|
|
101
|
+
return `The server could not save this ${camera}. Try again, and contact support if it keeps happening.`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (status === 400 && serverMessage) {
|
|
105
|
+
// Joi rejections arrive as `"host" is required`.
|
|
106
|
+
const field = serverMessage.match(/^"(\w+)"\s+(.+?)\.?$/);
|
|
107
|
+
|
|
108
|
+
if (field) {
|
|
109
|
+
const label = CAMERA_FIELD_LABELS[field[1]] ?? field[1];
|
|
110
|
+
const phrase =
|
|
111
|
+
CAMERA_VALIDATION_PHRASES.find(([pattern]) =>
|
|
112
|
+
pattern.test(field[2])
|
|
113
|
+
)?.[1] ?? `${field[2]}.`;
|
|
114
|
+
|
|
115
|
+
return `${label} ${phrase}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return serverMessage;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return errorConverter(error);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
|
|
33
125
|
/**
|
|
34
126
|
* A service-provider account can only be shown ITS OWN work orders, feedbacks
|
|
35
127
|
* and key logs, so every one of those screens scopes its request by the
|