@7365admin1/layer-common 4.97.1-staging.500 → 4.97.1-staging.501

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.
@@ -26,9 +26,9 @@
26
26
  <template v-else>
27
27
  <div class="hid-access-permissions__intro">
28
28
  <p class="text-body-2 text-medium-emphasis mb-0">
29
- Residents enrolled on this reader. Enrolling already grants access — switch
30
- somebody off to close the door to them without removing their face, card
31
- or PIN.
29
+ Everybody enrolled on this reader, by type. Enrolling already grants
30
+ access — switch somebody off to close the door to them without removing
31
+ their face, card or PIN.
32
32
  </p>
33
33
  </div>
34
34
 
@@ -45,12 +45,36 @@
45
45
  @refresh="loadEnrolled"
46
46
  @update:page="goToPage"
47
47
  >
48
+ <!-- WHICH OF THE READER'S PEOPLE TO SHOW.
49
+ The reader holds residents, members and contractors; each tab is
50
+ one `type`, filtered and paged by the server. The strip is
51
+ `TableMain`'s own slot and the shared `.app-tab` markup, the same
52
+ as every other tabbed table here - not a `v-tabs`, whose selected
53
+ state is drawn from Vuetify's `on-surface` rather than the
54
+ design's accent underline.
55
+ The count beside each label is the whole reader's, from the
56
+ draft, so it follows an unsaved tick. -->
57
+ <template #tabs>
58
+ <button
59
+ v-for="tab in TABS"
60
+ :key="tab.value"
61
+ type="button"
62
+ class="app-tab"
63
+ :class="{ 'app-tab--active': activeTab === tab.value }"
64
+ :aria-pressed="activeTab === tab.value"
65
+ @click="selectTab(tab.value)"
66
+ >
67
+ {{ tab.label }}
68
+ <span class="hid-access-permissions__tab-count">{{ tabCount(tab) }}</span>
69
+ </button>
70
+ </template>
71
+
48
72
  <template #extension>
49
73
  <div class="app-filter-row">
50
74
  <AppField
51
75
  v-model="search"
52
76
  search
53
- placeholder="Search name, unit or registration"
77
+ :placeholder="searchPlaceholder"
54
78
  @keyup.enter="searchFromFirstPage"
55
79
  />
56
80
  </div>
@@ -121,6 +145,7 @@ import {
121
145
  readCandidatePage,
122
146
  seedAssignments,
123
147
  setAssignment,
148
+ subjectOfIdentity,
124
149
  toAssignmentPayload,
125
150
  type HidAssignmentState,
126
151
  } from "../utils/hid-permission-assignments";
@@ -156,11 +181,52 @@ const emit = defineEmits<{
156
181
 
157
182
  const { getSitePermissions, getIdentities, updateSitePermissions } = useHidAmico();
158
183
 
159
- /* Name over unit in one cell rather than a "Unit" column — see `subtitleOf`. */
160
- const headers = [
161
- { title: "Resident", value: "name", sortable: false },
184
+ /**
185
+ * WHO EACH TAB LISTS, AND WHAT A GRANT MADE FROM IT IS FILED UNDER.
186
+ *
187
+ * `type` is the identity's own, filtered by the SERVER (`listIdentities` does an
188
+ * exact match on it), and it is what the enrolment writes and what
189
+ * `permissionIdentityType` re-confirms on every reconcile. `category` is the
190
+ * permission category a NEW grant from this tab belongs to.
191
+ *
192
+ * The two are a pair rather than one derived from the other, because the tab is
193
+ * the only thing on this screen that knows which of them the operator means: a
194
+ * contractor and a property-management member are both a `member` link, and
195
+ * guessing between them from the record is exactly what went wrong before.
196
+ */
197
+ const TABS = [
198
+ { value: "resident", label: "Resident", noun: "Resident", type: "resident", category: "resident" },
199
+ { value: "member", label: "Member", noun: "Member", type: "staff", category: "property_management" },
200
+ {
201
+ value: "provider",
202
+ label: "Service provider",
203
+ noun: "Person",
204
+ type: "contractor",
205
+ category: "service_provider_member",
206
+ },
207
+ ] as const satisfies ReadonlyArray<{
208
+ value: string;
209
+ label: string;
210
+ noun: string;
211
+ type: string;
212
+ category: THidPermissionCategory;
213
+ }>;
214
+
215
+ const activeTab = ref<(typeof TABS)[number]["value"]>("resident");
216
+ const currentTab = computed(
217
+ () => TABS.find((tab) => tab.value === activeTab.value) ?? TABS[0],
218
+ );
219
+
220
+ /** Granted counts per category, for the tab strip. One call, all three. */
221
+ const counts = ref<Record<string, number>>({});
222
+ const tabCount = (tab: (typeof TABS)[number]) => counts.value[tab.category] ?? 0;
223
+
224
+ /* Name over unit in one cell rather than a "Unit" column — see `subtitleOf`.
225
+ The first column follows the tab; "Door access" is the same action for all. */
226
+ const headers = computed(() => [
227
+ { title: currentTab.value.noun, value: "name", sortable: false },
162
228
  { title: "Door access", value: "selected", sortable: false, width: 190 },
163
- ];
229
+ ]);
164
230
 
165
231
  const search = ref("");
166
232
  const page = ref(1);
@@ -187,6 +253,13 @@ const readerLabel = computed(() => {
187
253
  return readerLocation.value ? `${name} · ${readerLocation.value}` : name;
188
254
  });
189
255
 
256
+ /* "unit" only means something for a resident. */
257
+ const searchPlaceholder = computed(() =>
258
+ activeTab.value === "resident"
259
+ ? "Search name, unit or registration"
260
+ : "Search name or registration",
261
+ );
262
+
190
263
  const assignedTotal = computed(() => draft.value.size);
191
264
  const dirty = computed(() => hasChanges(savedState.value, draft.value));
192
265
  /* The draft is the truth once the dialog is open, so `selected` is recomputed
@@ -199,6 +272,7 @@ watch(
199
272
  if (!isOpen || !readerId.value) return;
200
273
  search.value = "";
201
274
  page.value = 1;
275
+ activeTab.value = "resident";
202
276
  void loadAll();
203
277
  },
204
278
  { immediate: true },
@@ -221,6 +295,13 @@ async function loadAssignments() {
221
295
  const assignments = response?.data?.assignments ?? [];
222
296
  savedState.value = seedAssignments(assignments);
223
297
  draft.value = seedAssignments(assignments);
298
+ /*
299
+ * Counted from the assignments themselves rather than the server's `counts`
300
+ * block, so the strip follows the DRAFT and a tick updates the number it
301
+ * sits beside. `countByCategory` is the same helper the candidate screens
302
+ * use.
303
+ */
304
+ counts.value = countByCategory(draft.value);
224
305
  } catch (error) {
225
306
  showToast(getErrorMessage(error, "Unable to load this reader's permissions."), "error");
226
307
  }
@@ -230,27 +311,6 @@ function toRecord(value: unknown): Record<string, unknown> {
230
311
  return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
231
312
  }
232
313
 
233
- /**
234
- * The subject an identity names, in the order `createIdentity` validates them.
235
- *
236
- * A visitor returns null and is left out of the list: visitor access comes from
237
- * `user_access_rules`, not from a group, so there is nothing here to switch
238
- * off. An identity linked to nobody — a device user the reader holds that was
239
- * never tied to a person — is dropped for the same reason.
240
- */
241
- function subjectOf(identity: Record<string, unknown>) {
242
- if (identity.person) {
243
- return { subjectId: String(identity.person), category: "resident" as const };
244
- }
245
- if (identity.member) {
246
- return { subjectId: String(identity.member), category: "property_management" as const };
247
- }
248
- if (identity.serviceProvider) {
249
- return { subjectId: String(identity.serviceProvider), category: "service_provider" as const };
250
- }
251
- return null;
252
- }
253
-
254
314
  /**
255
315
  * Why a row cannot be switched, in words the reader of the screen can act on.
256
316
  *
@@ -294,10 +354,10 @@ async function loadEnrolled() {
294
354
  page: page.value,
295
355
  limit: 10,
296
356
  search: search.value.trim(),
297
- // Residents only, and filtered by the SERVER rather than here. The pager's
298
- // count comes back from the same query, so narrowing the list on this side
299
- // would print a total that never matched the rows under it.
300
- type: "resident",
357
+ // THIS TAB's people, and filtered by the SERVER rather than here. The
358
+ // pager's count comes back from the same query, so narrowing the list on
359
+ // this side would print a total that never matched the rows under it.
360
+ type: currentTab.value.type,
301
361
  });
302
362
  const result = readCandidatePage(response);
303
363
  /*
@@ -309,11 +369,13 @@ async function loadEnrolled() {
309
369
  */
310
370
  enrolled.value = result.items.map((row) => {
311
371
  const identity = toRecord(row);
312
- const subject = subjectOf(identity);
372
+ const subject = subjectOfIdentity(identity);
313
373
  const metadata = toRecord(identity.metadata);
314
374
  return {
315
375
  subjectId: subject?.subjectId ?? "",
316
- category: subject?.category ?? "resident",
376
+ // The record's own category where it has one; otherwise the tab's,
377
+ // which is the only thing that knows what the operator means.
378
+ category: subject?.category ?? currentTab.value.category,
317
379
  // The row's own key. `subjectId` is empty for the rows below, and two
318
380
  // empty keys would collapse into one row in the table.
319
381
  rowKey: String(identity._id ?? identity.hidUserId ?? Math.random()),
@@ -342,6 +404,24 @@ async function loadEnrolled() {
342
404
  }
343
405
  }
344
406
 
407
+ /**
408
+ * Switching tabs refetches, because the filter is the SERVER's.
409
+ *
410
+ * The draft is untouched: it is the whole reader's, and the save sends all of it
411
+ * whatever tab is in front of you. Clearing it here - or building the payload
412
+ * from the loaded rows - is what would revoke everybody on the other two tabs.
413
+ *
414
+ * Search is cleared with the tab. It is a server-side search within one type, so
415
+ * carrying it across would land on an empty list and look like the tab was empty.
416
+ */
417
+ function selectTab(value: (typeof TABS)[number]["value"]) {
418
+ if (activeTab.value === value) return;
419
+ activeTab.value = value;
420
+ search.value = "";
421
+ page.value = 1;
422
+ void loadEnrolled();
423
+ }
424
+
345
425
  function goToPage(value: number) {
346
426
  page.value = Number(value) || 1;
347
427
  void loadEnrolled();
@@ -352,15 +432,34 @@ function searchFromFirstPage() {
352
432
  void loadEnrolled();
353
433
  }
354
434
 
435
+ /**
436
+ * Grant or revoke one person.
437
+ *
438
+ * The category comes from the row where the server gave it one, and otherwise
439
+ * from the TAB - never from re-reading the identity's links on this side. A
440
+ * contractor and a property-management member are both a `member` link, and the
441
+ * stale local copy of that reading is what used to file contractors as staff: the
442
+ * reconcile then derived `staff` from that category and rewrote the person's
443
+ * type, moving them into the Member tab.
444
+ *
445
+ * An ALREADY GRANTED person keeps the category their assignment holds, so merely
446
+ * visiting a tab and toggling somebody off and on cannot re-file them.
447
+ */
355
448
  function toggle(item: THidPermissionCandidate, selected: boolean) {
356
- if (!String(item.subjectId ?? "")) return;
449
+ const subjectId = String(item.subjectId ?? "");
450
+ if (!subjectId) return;
451
+ const category = savedState.value.get(subjectId)?.category
452
+ ?? item.category
453
+ ?? currentTab.value.category;
357
454
  draft.value = setAssignment(
358
455
  draft.value,
359
456
  savedState.value,
360
- String(item.subjectId ?? ""),
361
- item.category,
457
+ subjectId,
458
+ category,
362
459
  selected,
363
460
  );
461
+ // The strip sits beside the rows, so it follows the tick rather than the save.
462
+ counts.value = countByCategory(draft.value);
364
463
  }
365
464
 
366
465
  async function save() {
@@ -401,6 +500,15 @@ function getErrorMessage(error: unknown, fallback: string) {
401
500
  </script>
402
501
 
403
502
  <style scoped lang="scss">
503
+ /* The granted count beside a tab label. Quieter than the label and set in
504
+ tabular figures so the three tabs do not shift width as numbers change. */
505
+ .hid-access-permissions__tab-count {
506
+ margin-left: 6px;
507
+ color: var(--muted);
508
+ font-weight: 600;
509
+ font-variant-numeric: tabular-nums;
510
+ }
511
+
404
512
  .hid-access-permissions {
405
513
  &__title { padding-block: 16px; }
406
514
 
@@ -969,6 +969,7 @@ import {
969
969
  memberCandidateOf,
970
970
  memberHasAccount,
971
971
  HID_ALL_SERVICES,
972
+ enrolmentSubjectOfCategory,
972
973
  permissionCategoryOf,
973
974
  providerAccountsKnown,
974
975
  providerCandidateOf,
@@ -993,6 +994,7 @@ import {
993
994
  import {
994
995
  seedAssignments,
995
996
  setAssignment,
997
+ subjectOfIdentity,
996
998
  toAssignmentPayload,
997
999
  type HidAssignmentState,
998
1000
  } from "../utils/hid-permission-assignments";
@@ -2570,22 +2572,20 @@ async function openEdit(user: HidUser) {
2570
2572
  form.photoPreview = "";
2571
2573
  facialEnrolled.value = hasFacialEnrollment(user);
2572
2574
  /*
2573
- * Read in the order `createIdentity` validates the links, with one extra
2574
- * test. A `member` link is how BOTH a property-management member and a
2575
- * service provider's own employee are stored - the person's membership at
2576
- * this site - so the identity's `type` is what tells them apart. Without
2577
- * that test a contractor's edit would be headed "Member".
2575
+ * ONE reading of the links, shared with the Access Permissions screen.
2576
+ * `subjectOfIdentity` knows that a `member` link is how BOTH a
2577
+ * property-management member and a service provider's own employee are stored,
2578
+ * and uses the identity's `type` to tell them apart - without which a
2579
+ * contractor's edit would be headed "Member".
2580
+ *
2581
+ * This was the same ternary written out inline. The other copy of it went
2582
+ * stale, so neither is written out any more.
2578
2583
  */
2579
- form.subjectCategory = user.person
2580
- ? "resident"
2581
- : user.serviceProvider
2582
- ? "service_provider"
2583
- : user.member && String(user.type ?? "") === "contractor"
2584
- ? "service_provider_staff"
2585
- : "property_management";
2586
- form.subjectId = String(
2587
- user.person || user.member || user.serviceProvider || ""
2588
- );
2584
+ const editedSubject = subjectOfIdentity(user as Record<string, unknown>);
2585
+ form.subjectCategory = enrolmentSubjectOfCategory(
2586
+ editedSubject?.category
2587
+ ) as TEnrolmentSubjectValue;
2588
+ form.subjectId = editedSubject?.subjectId ?? "";
2589
2589
  // The resident this user already holds is not a conflict with itself. A real
2590
2590
  // one only appears if the operator reassigns the row to somebody else.
2591
2591
  residentConflict.value = null;
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.97.1-staging.500",
5
+ "version": "4.97.1-staging.501",
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.",
@@ -240,6 +240,28 @@ export function permissionCategoryOf(category: unknown): string {
240
240
  return "property_management";
241
241
  }
242
242
 
243
+ /**
244
+ * The dropdown value for a grant's category — the inverse of
245
+ * `permissionCategoryOf`.
246
+ *
247
+ * An edit STATES the subject type rather than offering it, because an identity's
248
+ * category cannot change after it is created. This is how a stored record is
249
+ * turned back into the value the form holds, and it is wider than the dropdown on
250
+ * purpose: records in the wild carry `service_provider`, which names a COMPANY
251
+ * and which the dropdown does not offer.
252
+ *
253
+ * Paired with `subjectOfIdentity` so the mapping lives in one place. `openEdit`
254
+ * used to repeat the whole links-and-type ternary inline, which is how the copy
255
+ * in `HidAccessPermissions.vue` was able to go stale without anything noticing.
256
+ */
257
+ export function enrolmentSubjectOfCategory(category: unknown): string {
258
+ const value = String(category ?? "");
259
+ if (value === "resident") return "resident";
260
+ if (value === "service_provider_member") return "service_provider_staff";
261
+ if (value === "service_provider") return "service_provider";
262
+ return "property_management";
263
+ }
264
+
243
265
  /* ── PROVIDER STAFF AS PICKER ROWS ─────────────────────────────────────── */
244
266
 
245
267
  /**
@@ -142,11 +142,20 @@ export function applySelection(
142
142
 
143
143
  /** Per-category totals for the dialog header, counted over the whole reader. */
144
144
  export function countByCategory(state: HidAssignmentState): Record<THidPermissionCategory, number> {
145
- const counts = { resident: 0, property_management: 0, service_provider: 0 } as Record<
146
- THidPermissionCategory,
147
- number
148
- >;
149
- for (const value of state.values()) counts[value.category] += 1;
145
+ /*
146
+ * Every category seeded at zero, and the increment guarded.
147
+ * `counts[category] += 1` on a key this object does not hold yields NaN, so a
148
+ * category added to the union but missed here would print "NaN" beside a tab
149
+ * rather than a number. That is exactly what happened when
150
+ * `service_provider_member` was added.
151
+ */
152
+ const counts = HID_PERMISSION_CATEGORIES.reduce(
153
+ (seeded, category) => ({ ...seeded, [category]: 0 }),
154
+ {} as Record<THidPermissionCategory, number>,
155
+ );
156
+ for (const value of state.values()) {
157
+ counts[value.category] = (counts[value.category] ?? 0) + 1;
158
+ }
150
159
  return counts;
151
160
  }
152
161
 
@@ -196,3 +205,55 @@ export function readCandidatePage(response: unknown): {
196
205
  pageRange,
197
206
  };
198
207
  }
208
+
209
+ /* ── THE SUBJECT AN IDENTITY NAMES ─────────────────────────────────────── */
210
+
211
+ /**
212
+ * WHICH GRANT AN ENROLLED PERSON'S ACCESS IS FILED UNDER.
213
+ *
214
+ * Mirrors `permissionSubjectOfIdentity` in core, which is the authority. This
215
+ * repository cannot import it - layer-common does not depend on the core
216
+ * package - so there is a copy, and the point of putting it HERE is that there
217
+ * is only one.
218
+ *
219
+ * There used to be two: a local `subjectOf` in `HidAccessPermissions.vue` and
220
+ * the same ternary inline in `HidUserEnrollment.vue`. The first was stale. It
221
+ * read every `member` link as `property_management`, so granting a contractor
222
+ * from the permissions screen wrote a staff assignment - and the reconcile then
223
+ * derived `staff` from that category and rewrote the identity's `type`, moving
224
+ * the person out of the service-provider tab and into the member one. The
225
+ * screen for managing contractors reclassified them by being used.
226
+ *
227
+ * A `member` link is how BOTH a property-management member and a service
228
+ * provider's own employee are stored - the person's membership at this site -
229
+ * so the identity's `type` is the discriminator. `"contractor"` is written by
230
+ * the enrolment form and re-confirmed by `permissionIdentityType` on every
231
+ * reconcile, so the two agree instead of one overwriting the other.
232
+ *
233
+ * Returns null for a visitor and for an identity linked to nobody. Visitor
234
+ * access comes from `user_access_rules` rather than a group, so there is nothing
235
+ * to switch; an identity holding only an account link names no subject to
236
+ * assign. Both are still DRAWN by the permissions screen, disabled, with a
237
+ * reason - dropping them silently is what once made the pager say "1-9 of 9"
238
+ * over seven rows.
239
+ */
240
+ export function subjectOfIdentity(
241
+ identity: Record<string, unknown> | null | undefined,
242
+ ): { subjectId: string; category: THidPermissionCategory } | null {
243
+ if (!identity) return null;
244
+ if (identity.person) {
245
+ return { subjectId: String(identity.person), category: "resident" };
246
+ }
247
+ if (identity.member) {
248
+ return {
249
+ subjectId: String(identity.member),
250
+ category: String(identity.type ?? "") === "contractor"
251
+ ? "service_provider_member"
252
+ : "property_management",
253
+ };
254
+ }
255
+ if (identity.serviceProvider) {
256
+ return { subjectId: String(identity.serviceProvider), category: "service_provider" };
257
+ }
258
+ return null;
259
+ }