@7365admin1/layer-common 3.2.8-staging.210 → 3.2.8-staging.212

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.
@@ -101,6 +101,14 @@ type HidFacialEnrollmentResult = {
101
101
  errors?: Array<{ code?: number; message?: string }>;
102
102
  };
103
103
 
104
+ export type HidFacialSyncResult = {
105
+ readerId: string;
106
+ facialDetected: number;
107
+ syncedCount: number;
108
+ unmappedCount: number;
109
+ message: string;
110
+ };
111
+
104
112
  export type HidPhysicalCardType = "pacs" | "csn";
105
113
 
106
114
  export type HidPhysicalCard = {
@@ -191,6 +199,13 @@ export default function useHidAmico() {
191
199
  });
192
200
  }
193
201
 
202
+ function syncReaderFacialData(readerId: string) {
203
+ return useNuxtApp().$api<{ data: HidFacialSyncResult }>(
204
+ `${basePath}/readers/${readerId}/facial-sync`,
205
+ { method: "POST" },
206
+ );
207
+ }
208
+
194
209
  function configureReaderIntegration(readerId: string) {
195
210
  return useNuxtApp().$api<HidApiRecord>(`${basePath}/readers/${readerId}/configure`, {
196
211
  method: "POST",
@@ -254,6 +269,7 @@ export default function useHidAmico() {
254
269
  limit?: number;
255
270
  search?: string;
256
271
  status?: "mapped" | "unmapped" | "";
272
+ includeVisitors?: boolean;
257
273
  } = {}) {
258
274
  return useNuxtApp().$api<HidCollectionResponse>(`${basePath}/readers/${readerId}/users`, {
259
275
  method: "GET",
@@ -267,7 +283,7 @@ export default function useHidAmico() {
267
283
  search?: string;
268
284
  status?: "authorized" | "not_authorized" | "unknown" | "";
269
285
  method?: "facial" | "qr_code" | "id_password" | "pin" | "card" | "";
270
- tab?: "resident" | "visitor" | "administrator";
286
+ tab?: "all" | "unmapped" | "mapped" | "resident" | "visitor" | "administrator";
271
287
  } = {}) {
272
288
  return useNuxtApp().$api<HidCollectionResponse>(`${basePath}/readers/${readerId}/access-logs`, {
273
289
  method: "GET",
@@ -499,6 +515,7 @@ export default function useHidAmico() {
499
515
  deleteReader,
500
516
  testReader,
501
517
  syncReader,
518
+ syncReaderFacialData,
502
519
  configureReaderIntegration,
503
520
  setReaderMonitor,
504
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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "3.2.8-staging.210",
5
+ "version": "3.2.8-staging.212",
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>
@@ -5,7 +5,7 @@
5
5
  :org="orgId"
6
6
  message="Enable HID as a service for this site before managing HID users."
7
7
  >
8
- <HidUserEnrollment :site="siteId" />
8
+ <HidReaderUserRoster :site="siteId" />
9
9
  </HidEnabledGate>
10
10
  </v-container>
11
11
  </template>
@@ -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
+ }