@7365admin1/layer-common 3.2.1 → 3.2.2-staging.100

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.
Files changed (123) hide show
  1. package/.changeset/camera-wall-gated-endpoint.md +47 -0
  2. package/.changeset/camera-wall-plain-english.md +60 -0
  3. package/.changeset/camera-wall-selection-and-guidance.md +65 -0
  4. package/.changeset/dark-primary-contrast.md +39 -0
  5. package/.changeset/dashboard-dark-theme-contrast.md +15 -0
  6. package/.changeset/notification-preferences.md +17 -0
  7. package/.changeset/pm-sidebar-grouping-and-list-scaffold.md +27 -0
  8. package/.changeset/service-provider-invitation-actions.md +28 -0
  9. package/assets/css/primitives.css +680 -0
  10. package/assets/css/screens.css +541 -0
  11. package/assets/css/shell.css +258 -0
  12. package/assets/css/tokens.css +220 -0
  13. package/components/AccessCardQrTagging.vue +1 -1
  14. package/components/AccessManagement.vue +53 -72
  15. package/components/AppButton.vue +52 -0
  16. package/components/AppCard.vue +20 -0
  17. package/components/AppDateField.vue +35 -0
  18. package/components/AppField.vue +46 -0
  19. package/components/AppKpiTile.vue +65 -0
  20. package/components/AppSegmented.vue +38 -0
  21. package/components/AppSelect.vue +203 -0
  22. package/components/AreaChecklistHistoryMain.vue +6 -2
  23. package/components/AreaMain.vue +9 -17
  24. package/components/AttendanceMain.vue +4 -3
  25. package/components/Avatar/Main.vue +54 -2
  26. package/components/BillingMain.vue +26 -30
  27. package/components/BuildingManagement/buildings.vue +27 -38
  28. package/components/BuildingManagement/units.vue +26 -42
  29. package/components/BulletinBoardManagement.vue +23 -10
  30. package/components/CameraWall.vue +1034 -0
  31. package/components/CameraWallTile.vue +818 -0
  32. package/components/CardToolbar.vue +37 -0
  33. package/components/CleaningScheduleMain.vue +29 -46
  34. package/components/ClientMain.vue +90 -78
  35. package/components/DashboardEmptyState.vue +11 -1
  36. package/components/DashboardMain.vue +349 -275
  37. package/components/DashboardPanel.vue +1 -1
  38. package/components/DocumentManagement.vue +129 -93
  39. package/components/EntryPassInformation.vue +4 -4
  40. package/components/EntryPassMain.vue +49 -27
  41. package/components/EquipmentItemMain.vue +4 -6
  42. package/components/EquipmentManagementMain.vue +9 -7
  43. package/components/FeedbackMain.vue +42 -56
  44. package/components/FormDialog.vue +60 -21
  45. package/components/HidAccessLogDashboard.vue +119 -44
  46. package/components/HidCameraCaptureDialog.vue +199 -0
  47. package/components/HidIntercomManagement.vue +825 -202
  48. package/components/HidQrCodeConfiguration.vue +927 -93
  49. package/components/HidServiceSettingsPanel.vue +20 -1
  50. package/components/HidUserEnrollment.vue +454 -106
  51. package/components/InvitationMain.vue +46 -61
  52. package/components/Layout/Header.vue +289 -37
  53. package/components/Layout/NavigationDrawer.vue +340 -30
  54. package/components/ManageChecklistMain.vue +17 -17
  55. package/components/MemberMain.vue +79 -60
  56. package/components/MyAttendanceMain.vue +3 -5
  57. package/components/NavigationItem.vue +9 -0
  58. package/components/Nfc/NFCPatrolRouteMain.vue +116 -103
  59. package/components/NotificationSettings.vue +361 -0
  60. package/components/OnlineFormConfigurationForm.vue +620 -224
  61. package/components/OnlineFormFill.vue +36 -8
  62. package/components/OnlineFormsConfiguration.vue +7 -3
  63. package/components/PageHeader.vue +43 -0
  64. package/components/PassInformation.vue +104 -45
  65. package/components/QrTemplate/CreditCardLandscape.vue +19 -12
  66. package/components/QrTemplate/PrintDialog.vue +209 -196
  67. package/components/RolePermissionMain.vue +184 -91
  68. package/components/ScanVisitorQRCode.vue +35 -7
  69. package/components/ScheduleAreaMain.vue +15 -49
  70. package/components/ScheduleTaskMain.vue +23 -26
  71. package/components/ScheduleTastTicketMain.vue +18 -43
  72. package/components/ServiceProviderInvitationPrompt.vue +150 -0
  73. package/components/ServiceProviderMain.vue +346 -157
  74. package/components/SiteSettings.vue +25 -10
  75. package/components/SpecificAttr.vue +10 -2
  76. package/components/StatusChip.vue +93 -0
  77. package/components/SwitchContext.vue +33 -1
  78. package/components/TableHygiene.vue +29 -46
  79. package/components/TableList.vue +1 -1
  80. package/components/TableListSecondary.vue +1 -1
  81. package/components/TableMain.vue +161 -77
  82. package/components/TableV3.vue +61 -66
  83. package/components/UnitMain.vue +9 -17
  84. package/components/VehicleManagement.vue +20 -9
  85. package/components/VisitorForm.vue +294 -78
  86. package/components/VisitorManagement.vue +221 -71
  87. package/components/VisitorSocketPopUp.vue +56 -2
  88. package/components/WorkOrder/Main.vue +42 -51
  89. package/composables/useComment.ts +3 -3
  90. package/composables/useHidAmico.ts +151 -0
  91. package/composables/useHidNavigation.ts +0 -7
  92. package/composables/useLocalAuth.ts +9 -36
  93. package/composables/useNotificationPreference.ts +87 -0
  94. package/composables/useOnlineFormPages.ts +2 -0
  95. package/composables/useOrg.ts +1 -1
  96. package/composables/useRole.ts +22 -0
  97. package/composables/useServiceProviderInvitation.ts +145 -0
  98. package/composables/useSipWebPhone.ts +336 -0
  99. package/composables/useSiteSettings.ts +50 -0
  100. package/composables/useThemePreference.ts +63 -0
  101. package/composables/useVisitor.ts +6 -1
  102. package/composables/useVisitorSocket.ts +26 -0
  103. package/composables/useWebUsb.ts +127 -37
  104. package/nuxt.config.ts +31 -0
  105. package/package.json +4 -1
  106. package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
  107. package/pages/notification-settings.vue +19 -0
  108. package/plugins/secure-member.client.ts +21 -56
  109. package/plugins/vuetify.ts +68 -9
  110. package/test/visitor-socket.test.mjs +36 -0
  111. package/types/org.d.ts +2 -0
  112. package/types/site.d.ts +71 -0
  113. package/utils/camera-wall.test.ts +571 -0
  114. package/utils/camera-wall.ts +926 -0
  115. package/utils/nav-sections.test.ts +190 -0
  116. package/utils/nav-sections.ts +151 -0
  117. package/utils/status.test.ts +77 -0
  118. package/utils/status.ts +90 -0
  119. package/utils/theme.test.ts +534 -0
  120. package/utils/theme.ts +294 -0
  121. package/components/HidIdentityMapping.vue +0 -975
  122. package/middleware/member.ts +0 -4
  123. package/pages/[org]/[site]/access-mgmt/identity-mapping/index.vue +0 -23
@@ -50,7 +50,13 @@
50
50
 
51
51
  <v-card flat border class="table-card">
52
52
  <div class="table-refresh">
53
- <v-btn icon="mdi-refresh" variant="text" density="comfortable" :loading="loading" @click="loadUsers" />
53
+ <v-btn
54
+ icon="mdi-refresh"
55
+ variant="text"
56
+ density="comfortable"
57
+ :loading="loading"
58
+ @click="loadUsers"
59
+ />
54
60
  <div class="page-count">{{ pageRange }}</div>
55
61
  </div>
56
62
 
@@ -66,10 +72,18 @@
66
72
  </tr>
67
73
  </thead>
68
74
  <tbody>
69
- <tr v-for="user in users" :key="user._rowKey || user._id || user.hidUserId">
75
+ <tr
76
+ v-for="user in users"
77
+ :key="user._rowKey || user._id || user.hidUserId"
78
+ >
70
79
  <td>{{ getName(user) }}</td>
71
80
  <td>
72
- <v-avatar v-if="getUserImageSrc(user)" size="34" rounded="lg" class="hid-face-avatar">
81
+ <v-avatar
82
+ v-if="getUserImageSrc(user)"
83
+ size="34"
84
+ rounded="lg"
85
+ class="hid-face-avatar"
86
+ >
73
87
  <v-img :src="getUserImageSrc(user)" cover />
74
88
  </v-avatar>
75
89
  <span v-else>{{ getFacialData(user) }}</span>
@@ -88,13 +102,22 @@
88
102
  <td class="text-right">
89
103
  <v-menu>
90
104
  <template #activator="{ props: menuProps }">
91
- <v-btn v-bind="menuProps" icon="mdi-dots-vertical" variant="text" density="comfortable" />
105
+ <v-btn
106
+ v-bind="menuProps"
107
+ icon="mdi-dots-vertical"
108
+ variant="text"
109
+ density="comfortable"
110
+ />
92
111
  </template>
93
112
 
94
113
  <v-list density="compact" min-width="150">
95
114
  <v-list-item title="View" @click="openView(user)" />
96
115
  <v-list-item title="Edit" @click="openEdit(user)" />
97
- <v-list-item title="Delete" class="text-error" @click="openDelete(user)" />
116
+ <v-list-item
117
+ title="Delete"
118
+ class="text-error"
119
+ @click="openDelete(user)"
120
+ />
98
121
  </v-list>
99
122
  </v-menu>
100
123
  </td>
@@ -109,8 +132,26 @@
109
132
 
110
133
  <div class="table-footer">
111
134
  <span>{{ pageRange }}</span>
112
- <v-btn icon="mdi-chevron-left" variant="text" density="comfortable" :disabled="page <= 1" @click="page--; loadUsers()" />
113
- <v-btn icon="mdi-chevron-right" variant="text" density="comfortable" :disabled="page >= pages" @click="page++; loadUsers()" />
135
+ <v-btn
136
+ icon="mdi-chevron-left"
137
+ variant="text"
138
+ density="comfortable"
139
+ :disabled="page <= 1"
140
+ @click="
141
+ page--;
142
+ loadUsers();
143
+ "
144
+ />
145
+ <v-btn
146
+ icon="mdi-chevron-right"
147
+ variant="text"
148
+ density="comfortable"
149
+ :disabled="page >= pages"
150
+ @click="
151
+ page++;
152
+ loadUsers();
153
+ "
154
+ />
114
155
  </div>
115
156
  </v-card>
116
157
 
@@ -125,7 +166,12 @@
125
166
  <v-menu>
126
167
  <template #activator="{ props: menuProps }">
127
168
  <button v-bind="menuProps" class="photo-button" type="button">
128
- <v-img v-if="form.photoPreview" :src="form.photoPreview" cover class="photo-preview" />
169
+ <v-img
170
+ v-if="form.photoPreview"
171
+ :src="form.photoPreview"
172
+ cover
173
+ class="photo-preview"
174
+ />
129
175
  <span v-if="form.photoPreview" class="photo-edit-icon">
130
176
  <v-icon icon="mdi-pencil" size="16" />
131
177
  </span>
@@ -137,10 +183,25 @@
137
183
  </template>
138
184
  <v-list density="compact" min-width="190">
139
185
  <v-list-item title="Browse" @click="triggerPhotoInput" />
140
- <v-list-item title="Take Photo using Camera" @click="triggerPhotoInput" />
186
+ <v-list-item
187
+ title="Take Photo using Camera"
188
+ @click="triggerCameraInput"
189
+ />
190
+ <v-list-item
191
+ v-if="form.photoPreview"
192
+ title="Remove Photo"
193
+ class="text-error"
194
+ @click="removePhoto"
195
+ />
141
196
  </v-list>
142
197
  </v-menu>
143
- <input ref="photoInput" type="file" accept="image/*" class="hidden-input" @change="onPhotoChange" />
198
+ <input
199
+ ref="photoInput"
200
+ type="file"
201
+ accept="image/*"
202
+ class="hidden-input"
203
+ @change="onPhotoChange"
204
+ />
144
205
  </div>
145
206
 
146
207
  <div class="field-label">Name <span>*</span></div>
@@ -177,57 +238,83 @@
177
238
  class="mb-3"
178
239
  />
179
240
 
180
- <div class="field-label">Panic Pin (optional)</div>
241
+ <div class="field-label">Access PIN (optional)</div>
181
242
  <v-text-field
182
- v-model="form.cardNo"
183
- aria-label="Panic Pin"
184
- placeholder="Enter panic pin"
243
+ v-model="form.accessPin"
244
+ aria-label="Access PIN"
245
+ placeholder="Enter numeric access PIN"
185
246
  density="compact"
186
247
  variant="outlined"
187
- hide-details="auto"
248
+ inputmode="numeric"
249
+ autocomplete="new-password"
250
+ :type="showPin ? 'text' : 'password'"
251
+ :append-inner-icon="showPin ? 'mdi-eye-off' : 'mdi-eye'"
252
+ :hint="selectedUser && pinEnrolled ? 'Leave blank to keep the current PIN.' : 'PIN works as an alternative to face or QR.'"
253
+ persistent-hint
254
+ @click:append-inner="showPin = !showPin"
188
255
  class="mb-3"
189
256
  />
190
-
191
- <div class="field-label">Panic Password (optional)</div>
192
- <v-text-field
193
- v-model="form.pinPassword"
194
- aria-label="Panic Password"
195
- placeholder="Enter password"
257
+ <v-checkbox
258
+ v-if="selectedUser && pinEnrolled"
259
+ v-model="removePinRequested"
260
+ label="Remove current access PIN"
196
261
  density="compact"
197
- variant="outlined"
198
- hide-details="auto"
199
- :type="showPassword ? 'text' : 'password'"
200
- :append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
201
- @click:append-inner="showPassword = !showPassword"
262
+ hide-details
202
263
  />
203
264
  </v-card-text>
204
265
 
205
266
  <v-card-actions class="pa-0">
206
- <v-btn class="text-none action-cancel" variant="flat" height="44" @click="formDialog = false">Cancel</v-btn>
207
- <v-btn class="text-none action-submit" variant="flat" height="44" :loading="saving" @click="saveUser">
267
+ <v-btn
268
+ class="text-none action-cancel"
269
+ variant="flat"
270
+ height="44"
271
+ @click="formDialog = false"
272
+ >Cancel</v-btn
273
+ >
274
+ <v-btn
275
+ class="text-none action-submit"
276
+ variant="flat"
277
+ height="44"
278
+ :loading="saving"
279
+ @click="saveUser"
280
+ >
208
281
  Save
209
282
  </v-btn>
210
283
  </v-card-actions>
211
284
  </v-card>
212
285
  </v-dialog>
213
286
 
287
+ <HidCameraCaptureDialog v-model="cameraDialog" @capture="onCameraCapture" />
288
+
214
289
  <v-dialog v-model="viewDialog" max-width="430">
215
290
  <v-card rounded="lg">
216
291
  <v-card-title class="small-title">User Information</v-card-title>
217
292
  <v-card-text>
218
293
  <div class="detail-grid">
219
294
  <span>Name</span><strong>{{ getName(selectedUser) }}</strong>
220
- <span>User ID</span><strong>{{ selectedUser ? formatHidUid(selectedUser.hidUserId) : "N/A" }}</strong>
221
- <span>Registration No.</span><strong>{{ selectedUser?.registration || "N/A" }}</strong>
222
- <span>Last access time</span><strong>{{ formatDate(selectedUser?.metadata?.lastAccessAt) }}</strong>
223
- <span>Status</span><strong>{{ selectedUser ? getMappingStatus(selectedUser) : "N/A" }}</strong>
224
- <span>Panic Pin</span><strong>{{ selectedUser?.cardNo || "N/A" }}</strong>
225
- <span>Private Password</span><strong>{{ selectedUser?.metadata?.pinPassword ? "********" : "N/A" }}</strong>
295
+ <span>User ID</span
296
+ ><strong>{{
297
+ selectedUser ? formatHidUid(selectedUser.hidUserId) : "N/A"
298
+ }}</strong>
299
+ <span>Registration No.</span
300
+ ><strong>{{ selectedUser?.registration || "N/A" }}</strong>
301
+ <span>Last access time</span
302
+ ><strong>{{
303
+ formatDate(selectedUser?.metadata?.lastAccessAt)
304
+ }}</strong>
305
+ <span>Status</span
306
+ ><strong>{{
307
+ selectedUser ? getMappingStatus(selectedUser) : "N/A"
308
+ }}</strong>
309
+ <span>Access PIN</span
310
+ ><strong>{{ selectedUser?.metadata?.pinEnrolled ? "Configured" : "N/A" }}</strong>
226
311
  </div>
227
312
  </v-card-text>
228
313
  <v-card-actions>
229
314
  <v-spacer />
230
- <v-btn class="text-none" variant="text" @click="viewDialog = false">Close</v-btn>
315
+ <v-btn class="text-none" variant="text" @click="viewDialog = false"
316
+ >Close</v-btn
317
+ >
231
318
  </v-card-actions>
232
319
  </v-card>
233
320
  </v-dialog>
@@ -238,12 +325,26 @@
238
325
  <v-card-text class="text-body-2">
239
326
  Are you sure you want to permanently delete this HID user?
240
327
  <div class="text-caption text-medium-emphasis mt-2">
241
- This action will remove the user from the HID reader and mark the identity as deleted.
328
+ This action will remove the user from the HID reader and mark the
329
+ identity as deleted.
242
330
  </div>
243
331
  </v-card-text>
244
332
  <v-card-actions class="pa-0">
245
- <v-btn class="text-none action-cancel" variant="flat" height="44" @click="deleteDialog = false">Cancel</v-btn>
246
- <v-btn class="text-none action-submit" variant="flat" height="44" :loading="deleting" @click="deleteUser">Delete</v-btn>
333
+ <v-btn
334
+ class="text-none action-cancel"
335
+ variant="flat"
336
+ height="44"
337
+ @click="deleteDialog = false"
338
+ >Cancel</v-btn
339
+ >
340
+ <v-btn
341
+ class="text-none action-submit"
342
+ variant="flat"
343
+ height="44"
344
+ :loading="deleting"
345
+ @click="deleteUser"
346
+ >Delete</v-btn
347
+ >
247
348
  </v-card-actions>
248
349
  </v-card>
249
350
  </v-dialog>
@@ -270,6 +371,11 @@ const {
270
371
  getReaders,
271
372
  getIdentities,
272
373
  getUserImage,
374
+ setUserImage,
375
+ deleteUserImage,
376
+ getUserPinStatus,
377
+ setUserPin,
378
+ deleteUserPin,
273
379
  runObjectOperation,
274
380
  createIdentity,
275
381
  updateIdentity,
@@ -294,8 +400,13 @@ const formDialog = ref(false);
294
400
  const viewDialog = ref(false);
295
401
  const deleteDialog = ref(false);
296
402
  const selectedUser = ref<Record<string, any> | null>(null);
297
- const showPassword = ref(false);
403
+ const showPin = ref(false);
404
+ const pinEnrolled = ref(false);
298
405
  const photoInput = ref<HTMLInputElement | null>(null);
406
+ const cameraDialog = ref(false);
407
+ const selectedPhotoFile = ref<File | null>(null);
408
+ const removePhotoRequested = ref(false);
409
+ const removePinRequested = ref(false);
299
410
  const snackbar = reactive({
300
411
  show: false,
301
412
  color: "success",
@@ -310,8 +421,7 @@ const form = reactive({
310
421
  name: "",
311
422
  hidUserId: "",
312
423
  registration: "",
313
- cardNo: "",
314
- pinPassword: "",
424
+ accessPin: "",
315
425
  photoPreview: "",
316
426
  });
317
427
 
@@ -321,7 +431,7 @@ const readerOptions = computed(() =>
321
431
  readers.value.map((reader) => ({
322
432
  title: reader.name || reader.deviceId || reader._id,
323
433
  value: reader._id,
324
- })),
434
+ }))
325
435
  );
326
436
 
327
437
  const pageRange = computed(() => {
@@ -335,7 +445,7 @@ const pageRange = computed(() => {
335
445
  watch(
336
446
  () => props.site,
337
447
  () => init(),
338
- { immediate: true },
448
+ { immediate: true }
339
449
  );
340
450
 
341
451
  async function init() {
@@ -346,7 +456,8 @@ async function init() {
346
456
  async function loadReaders() {
347
457
  if (!props.site) return;
348
458
  const response = await getReaders({ site: props.site, page: 1, limit: 100 });
349
- readers.value = response?.items ?? response?.data?.items ?? response?.data?.readers ?? [];
459
+ readers.value =
460
+ response?.items ?? response?.data?.items ?? response?.data?.readers ?? [];
350
461
  if (!selectedReaderId.value && readers.value.length) {
351
462
  selectedReaderId.value = readers.value[0]._id;
352
463
  }
@@ -375,12 +486,19 @@ async function loadUsers() {
375
486
  limit: 500,
376
487
  offset: 0,
377
488
  }),
378
- isAdministratorIdentity() ? loadAdministratorRoles(selectedReaderId.value) : Promise.resolve(null),
489
+ isAdministratorIdentity()
490
+ ? loadAdministratorRoles(selectedReaderId.value)
491
+ : Promise.resolve(null),
379
492
  ]);
380
493
 
381
- const identities = identityResponse?.items ?? identityResponse?.data?.items ?? identityResponse?.data?.identities ?? [];
494
+ const identities =
495
+ identityResponse?.items ??
496
+ identityResponse?.data?.items ??
497
+ identityResponse?.data?.identities ??
498
+ [];
382
499
  const adminRoleIds = getAdministratorRoleUserIds(roleResponse);
383
500
  const readerUsers = normalizeHidUsers(readerResponse).filter((user) => {
501
+ if (Number(user.userTypeId) === 1) return false;
384
502
  if (!isAdministratorIdentity()) return true;
385
503
  const hidUserId = toHidNumericId(user.hidUserId);
386
504
  return Boolean(hidUserId && adminRoleIds.has(hidUserId));
@@ -409,21 +527,23 @@ async function loadUsers() {
409
527
  async function loadUserImages(items: Record<string, any>[]) {
410
528
  if (!selectedReaderId.value) return;
411
529
 
412
- await Promise.all(items.map(async (user) => {
413
- const hidUserId = toHidNumericId(user.hidUserId);
414
- const key = String(hidUserId || "");
415
- if (!hidUserId || userImages.value[key] || user?.metadata?.photo) return;
416
-
417
- try {
418
- const response = await getUserImage(selectedReaderId.value, hidUserId);
419
- const image = response?.data || response;
420
- userImages.value[key] = image?.base64
421
- ? `data:${image.contentType || "image/jpeg"};base64,${image.base64}`
422
- : "";
423
- } catch {
424
- userImages.value[key] = "";
425
- }
426
- }));
530
+ await Promise.all(
531
+ items.map(async (user) => {
532
+ const hidUserId = toHidNumericId(user.hidUserId);
533
+ const key = String(hidUserId || "");
534
+ if (!hidUserId || userImages.value[key] || user?.metadata?.photo) return;
535
+
536
+ try {
537
+ const response = await getUserImage(selectedReaderId.value, hidUserId);
538
+ const image = response?.data || response;
539
+ userImages.value[key] = image?.base64
540
+ ? `data:${image.contentType || "image/jpeg"};base64,${image.base64}`
541
+ : "";
542
+ } catch {
543
+ userImages.value[key] = "";
544
+ }
545
+ })
546
+ );
427
547
  }
428
548
 
429
549
  function resetForm() {
@@ -434,9 +554,13 @@ function resetForm() {
434
554
  form.name = "";
435
555
  form.hidUserId = "";
436
556
  form.registration = "";
437
- form.cardNo = "";
438
- form.pinPassword = "";
557
+ form.accessPin = "";
558
+ pinEnrolled.value = false;
559
+ showPin.value = false;
560
+ removePinRequested.value = false;
439
561
  form.photoPreview = "";
562
+ selectedPhotoFile.value = null;
563
+ removePhotoRequested.value = false;
440
564
  }
441
565
 
442
566
  async function openEnroll() {
@@ -460,11 +584,24 @@ async function openEdit(user: Record<string, any>) {
460
584
  form.name = getName(user);
461
585
  form.hidUserId = formatHidUid(user.hidUserId);
462
586
  form.registration = user.registration ?? "";
463
- form.cardNo = user.cardNo ?? "";
464
- form.pinPassword = user.metadata?.pinPassword ?? "";
587
+ form.accessPin = "";
588
+ pinEnrolled.value = Boolean(user.metadata?.pinEnrolled);
589
+ removePinRequested.value = false;
465
590
  form.photoPreview = getUserImageSrc(user);
591
+ selectedPhotoFile.value = null;
592
+ removePhotoRequested.value = false;
466
593
  formDialog.value = true;
467
594
 
595
+ try {
596
+ const status = await getUserPinStatus(
597
+ form.reader,
598
+ toHidNumericId(user.hidUserId),
599
+ );
600
+ pinEnrolled.value = Boolean(status?.data?.pinEnrolled);
601
+ } catch {
602
+ // Preserve the last known status if the reader is temporarily offline.
603
+ }
604
+
468
605
  if (!form.photoPreview) {
469
606
  await loadUserImages([user]);
470
607
  if (selectedUser.value?._rowKey === user._rowKey) {
@@ -487,9 +624,53 @@ function triggerPhotoInput() {
487
624
  photoInput.value?.click();
488
625
  }
489
626
 
627
+ function triggerCameraInput() {
628
+ cameraDialog.value = true;
629
+ }
630
+
631
+ function onCameraCapture(dataUrl: string) {
632
+ const [header, encodedData] = dataUrl.split(",", 2);
633
+ if (!header || !encodedData) {
634
+ showToast("Unable to capture the facial image.", "error");
635
+ return;
636
+ }
637
+
638
+ const mimeType = header.match(/^data:([^;]+);base64$/)?.[1] || "image/jpeg";
639
+ const decodedData = atob(encodedData);
640
+ const bytes = new Uint8Array(decodedData.length);
641
+ for (let index = 0; index < decodedData.length; index += 1) {
642
+ bytes[index] = decodedData.charCodeAt(index);
643
+ }
644
+
645
+ selectedPhotoFile.value = new File([bytes], `hid-facial-${Date.now()}.jpg`, {
646
+ type: mimeType,
647
+ });
648
+ form.photoPreview = dataUrl;
649
+ removePhotoRequested.value = false;
650
+ }
651
+
652
+ function removePhoto() {
653
+ form.photoPreview = "";
654
+ selectedPhotoFile.value = null;
655
+ removePhotoRequested.value = true;
656
+ }
657
+
490
658
  function onPhotoChange(event: Event) {
491
- const file = (event.target as HTMLInputElement).files?.[0];
659
+ const input = event.target as HTMLInputElement;
660
+ const file = input.files?.[0];
661
+ input.value = "";
492
662
  if (!file) return;
663
+ if (!["image/jpeg", "image/png"].includes(file.type)) {
664
+ showToast("Facial enrollment only accepts JPG or PNG images.", "error");
665
+ return;
666
+ }
667
+ if (file.size > 2 * 1024 * 1024) {
668
+ showToast("Facial enrollment images must be smaller than 2 MB.", "error");
669
+ return;
670
+ }
671
+
672
+ selectedPhotoFile.value = file;
673
+ removePhotoRequested.value = false;
493
674
  const reader = new FileReader();
494
675
  reader.onload = () => {
495
676
  form.photoPreview = String(reader.result || "");
@@ -498,11 +679,16 @@ function onPhotoChange(event: Event) {
498
679
  }
499
680
 
500
681
  async function saveUser() {
501
- const readerId = selectedReaderId.value || form.reader || readers.value[0]?._id;
682
+ const readerId =
683
+ selectedReaderId.value || form.reader || readers.value[0]?._id;
502
684
  if (!readerId || !form.name || !form.hidUserId || !form.registration) {
503
685
  showToast("Please complete the required HID user fields.", "error");
504
686
  return;
505
687
  }
688
+ if (form.accessPin && !/^\d{1,32}$/.test(form.accessPin)) {
689
+ showToast("Access PIN must contain numbers only.", "error");
690
+ return;
691
+ }
506
692
 
507
693
  saving.value = true;
508
694
  try {
@@ -519,8 +705,13 @@ async function saveUser() {
519
705
  const payload = {
520
706
  hidUserId: String(hidUser.id),
521
707
  registration: form.registration,
522
- cardNo: form.cardNo,
523
- type: props.identityType as "resident" | "staff" | "contractor" | "visitor" | "admin" | "unknown",
708
+ type: props.identityType as
709
+ | "resident"
710
+ | "staff"
711
+ | "contractor"
712
+ | "visitor"
713
+ | "admin"
714
+ | "unknown",
524
715
  status: "active" as const,
525
716
  metadata: {
526
717
  name: form.name,
@@ -530,13 +721,18 @@ async function saveUser() {
530
721
  unitLabel: buildUnitLabel(),
531
722
  facialData: form.photoPreview ? form.hidUserId : "",
532
723
  photo: form.photoPreview,
533
- pinPassword: form.pinPassword,
724
+ imageTimestamp: selectedUser.value?.metadata?.imageTimestamp || "",
725
+ facialScores: selectedUser.value?.metadata?.facialScores || {},
726
+ pinEnrolled: pinEnrolled.value,
534
727
  },
535
728
  };
536
729
 
537
730
  if (selectedUser.value) {
538
731
  await updateHidUserOnReader(readerId, hidUser);
539
732
  await syncHidUserRole(readerId, hidUser.id, isAdministratorIdentity());
733
+ const facialResult = await syncFacialImage(readerId, hidUser.id);
734
+ applyFacialResult(payload.metadata, facialResult);
735
+ payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
540
736
  if (selectedUser.value._id) {
541
737
  await updateIdentity(selectedUser.value._id, payload);
542
738
  } else {
@@ -545,13 +741,20 @@ async function saveUser() {
545
741
  } else {
546
742
  await createHidUserOnReader(readerId, hidUser);
547
743
  await syncHidUserRole(readerId, hidUser.id, isAdministratorIdentity());
744
+ const facialResult = await syncFacialImage(readerId, hidUser.id);
745
+ applyFacialResult(payload.metadata, facialResult);
746
+ payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
548
747
  await saveIdentityOnReader(readerId, payload);
549
748
  selectedReaderId.value = readerId;
550
749
  }
551
750
 
552
751
  formDialog.value = false;
553
752
  await loadUsers();
554
- showToast(selectedUser.value?._id ? "HID user updated on reader." : "HID user enrolled on reader.");
753
+ showToast(
754
+ selectedUser.value?._id
755
+ ? "HID user updated on reader."
756
+ : "HID user enrolled on reader."
757
+ );
555
758
  } catch (error: any) {
556
759
  console.error("Unable to save HID user:", error);
557
760
  showToast(getHidErrorMessage(error), "error");
@@ -560,12 +763,86 @@ async function saveUser() {
560
763
  }
561
764
  }
562
765
 
766
+ async function syncAccessPin(readerId: string, hidUserId: number) {
767
+ if (removePinRequested.value) {
768
+ await deleteUserPin(readerId, hidUserId);
769
+ pinEnrolled.value = false;
770
+ return false;
771
+ }
772
+ if (form.accessPin) {
773
+ await setUserPin(readerId, hidUserId, form.accessPin);
774
+ form.accessPin = "";
775
+ pinEnrolled.value = true;
776
+ return true;
777
+ }
778
+ return pinEnrolled.value;
779
+ }
780
+
781
+ type FacialSyncResult = {
782
+ action: "none" | "enrolled" | "removed";
783
+ timestamp?: number;
784
+ scores?: Record<string, unknown>;
785
+ };
786
+
787
+ async function syncFacialImage(
788
+ readerId: string,
789
+ hidUserId: number
790
+ ): Promise<FacialSyncResult> {
791
+ if (selectedPhotoFile.value) {
792
+ const timestamp = Math.floor(Date.now() / 1000);
793
+ const response = await setUserImage(
794
+ readerId,
795
+ hidUserId,
796
+ selectedPhotoFile.value,
797
+ {
798
+ timestamp,
799
+ match: true,
800
+ }
801
+ );
802
+ const result = response?.data;
803
+ userImages.value[String(hidUserId)] = form.photoPreview;
804
+ return { action: "enrolled", timestamp, scores: result?.scores };
805
+ }
806
+
807
+ if (removePhotoRequested.value) {
808
+ await deleteUserImage(readerId, hidUserId);
809
+ delete userImages.value[String(hidUserId)];
810
+ return { action: "removed" };
811
+ }
812
+
813
+ return { action: "none" };
814
+ }
815
+
816
+ function applyFacialResult(
817
+ metadata: Record<string, unknown>,
818
+ result: FacialSyncResult
819
+ ) {
820
+ if (result.action === "enrolled") {
821
+ metadata.facialData = form.hidUserId;
822
+ metadata.imageTimestamp = String(result.timestamp || "");
823
+ metadata.facialScores = result.scores || {};
824
+ } else if (result.action === "removed") {
825
+ metadata.facialData = "";
826
+ metadata.photo = "";
827
+ metadata.imageTimestamp = "";
828
+ metadata.facialScores = {};
829
+ }
830
+ }
831
+
563
832
  async function deleteUser() {
564
833
  if (!selectedUser.value) return;
565
834
 
566
835
  deleting.value = true;
567
836
  try {
568
- await syncHidUserRole(selectedReaderId.value, toHidNumericId(selectedUser.value.hidUserId), false);
837
+ await syncHidUserRole(
838
+ selectedReaderId.value,
839
+ toHidNumericId(selectedUser.value.hidUserId),
840
+ false
841
+ );
842
+ await deleteUserPin(
843
+ selectedReaderId.value,
844
+ toHidNumericId(selectedUser.value.hidUserId),
845
+ );
569
846
  await deleteHidUserFromReader(selectedReaderId.value, selectedUser.value);
570
847
  if (selectedUser.value._id) {
571
848
  await deleteIdentity(selectedUser.value._id);
@@ -581,8 +858,14 @@ async function deleteUser() {
581
858
  }
582
859
  }
583
860
 
584
- async function saveIdentityOnReader(readerId: string, payload: Record<string, any>) {
585
- const existingIdentity = await findExistingIdentityOnReader(readerId, payload);
861
+ async function saveIdentityOnReader(
862
+ readerId: string,
863
+ payload: Record<string, any>
864
+ ) {
865
+ const existingIdentity = await findExistingIdentityOnReader(
866
+ readerId,
867
+ payload
868
+ );
586
869
  if (existingIdentity?._id) {
587
870
  await updateIdentity(existingIdentity._id, payload);
588
871
  return;
@@ -594,31 +877,47 @@ async function saveIdentityOnReader(readerId: string, payload: Record<string, an
594
877
  const message = getHidErrorMessage(error);
595
878
  if (!message.toLowerCase().includes("already exists")) throw error;
596
879
 
597
- const duplicatedIdentity = await findExistingIdentityOnReader(readerId, payload);
880
+ const duplicatedIdentity = await findExistingIdentityOnReader(
881
+ readerId,
882
+ payload
883
+ );
598
884
  if (!duplicatedIdentity?._id) throw error;
599
885
  await updateIdentity(duplicatedIdentity._id, payload);
600
886
  }
601
887
  }
602
888
 
603
- async function findExistingIdentityOnReader(readerId: string, payload: Record<string, any>) {
889
+ async function findExistingIdentityOnReader(
890
+ readerId: string,
891
+ payload: Record<string, any>
892
+ ) {
604
893
  const response = await getIdentities(readerId, {
605
894
  page: 1,
606
895
  limit: 500,
607
896
  search: payload.hidUserId || payload.registration || payload.cardNo || "",
608
897
  });
609
- const identities = response?.items ?? response?.data?.items ?? response?.data?.identities ?? [];
898
+ const identities =
899
+ response?.items ??
900
+ response?.data?.items ??
901
+ response?.data?.identities ??
902
+ [];
610
903
  const hidUserId = String(payload.hidUserId || "");
611
904
  const registration = String(payload.registration || "");
612
905
  const cardNo = String(payload.cardNo || "");
613
906
 
614
- return identities.find((identity: Record<string, any>) =>
615
- String(identity.hidUserId || "") === hidUserId ||
616
- Boolean(registration && String(identity.registration || "") === registration) ||
617
- Boolean(cardNo && String(identity.cardNo || "") === cardNo),
907
+ return identities.find(
908
+ (identity: Record<string, any>) =>
909
+ String(identity.hidUserId || "") === hidUserId ||
910
+ Boolean(
911
+ registration && String(identity.registration || "") === registration
912
+ ) ||
913
+ Boolean(cardNo && String(identity.cardNo || "") === cardNo)
618
914
  );
619
915
  }
620
916
 
621
- async function createHidUserOnReader(readerId: string, user: Record<string, any>) {
917
+ async function createHidUserOnReader(
918
+ readerId: string,
919
+ user: Record<string, any>
920
+ ) {
622
921
  const existing = await getHidUserFromReader(readerId, user.id);
623
922
  if (existing) {
624
923
  await updateHidUserOnReader(readerId, user);
@@ -648,7 +947,10 @@ async function getHidUserFromReader(readerId: string, id: unknown) {
648
947
  return normalizeHidUsers(response)[0];
649
948
  }
650
949
 
651
- async function updateHidUserOnReader(readerId: string, user: Record<string, any>) {
950
+ async function updateHidUserOnReader(
951
+ readerId: string,
952
+ user: Record<string, any>
953
+ ) {
652
954
  const { id, ...values } = user;
653
955
  await runObjectOperation(readerId, {
654
956
  operation: "modify",
@@ -662,7 +964,10 @@ async function updateHidUserOnReader(readerId: string, user: Record<string, any>
662
964
  });
663
965
  }
664
966
 
665
- async function deleteHidUserFromReader(readerId: string, user: Record<string, any>) {
967
+ async function deleteHidUserFromReader(
968
+ readerId: string,
969
+ user: Record<string, any>
970
+ ) {
666
971
  const id = toHidNumericId(user.hidUserId);
667
972
  if (!readerId || !id) return;
668
973
 
@@ -677,7 +982,11 @@ async function deleteHidUserFromReader(readerId: string, user: Record<string, an
677
982
  });
678
983
  }
679
984
 
680
- async function syncHidUserRole(readerId: string, hidUserId: unknown, isAdministrator: boolean) {
985
+ async function syncHidUserRole(
986
+ readerId: string,
987
+ hidUserId: unknown,
988
+ isAdministrator: boolean
989
+ ) {
681
990
  const userId = toHidNumericId(hidUserId);
682
991
  if (!readerId || !userId) return;
683
992
 
@@ -771,14 +1080,17 @@ function getAdministratorRoleUserIds(response: Record<string, any> | null) {
771
1080
  return new Set(
772
1081
  (Array.isArray(roles) ? roles : [])
773
1082
  .map((role) => toHidNumericId(role.user_id))
774
- .filter((userId): userId is number => Boolean(userId)),
1083
+ .filter((userId): userId is number => Boolean(userId))
775
1084
  );
776
1085
  }
777
1086
 
778
1087
  function toHidUserObject(identity: Record<string, any>) {
779
1088
  return {
780
1089
  id: toHidNumericId(identity.hidUserId),
781
- name: identity.metadata?.name || identity.name || `User ${identity.hidUserId || ""}`.trim(),
1090
+ name:
1091
+ identity.metadata?.name ||
1092
+ identity.name ||
1093
+ `User ${identity.hidUserId || ""}`.trim(),
782
1094
  registration: identity.registration || "",
783
1095
  };
784
1096
  }
@@ -807,23 +1119,40 @@ function getName(user?: Record<string, any> | null) {
807
1119
  }
808
1120
 
809
1121
  function getUnit(user?: Record<string, any> | null) {
810
- return user?.metadata?.unitLabel || [user?.metadata?.block, user?.metadata?.level, user?.metadata?.unit].filter(Boolean).join("/") || "N/A";
1122
+ return (
1123
+ user?.metadata?.unitLabel ||
1124
+ [user?.metadata?.block, user?.metadata?.level, user?.metadata?.unit]
1125
+ .filter(Boolean)
1126
+ .join("/") ||
1127
+ "N/A"
1128
+ );
811
1129
  }
812
1130
 
813
1131
  function getFacialData(user: Record<string, any>) {
814
- return user?.metadata?.facialData || user?.metadata?.imageTimestamp || (user?.metadata?.photo ? user.hidUserId : "N/A");
1132
+ return (
1133
+ user?.metadata?.facialData ||
1134
+ user?.metadata?.imageTimestamp ||
1135
+ (user?.metadata?.photo ? user.hidUserId : "N/A")
1136
+ );
815
1137
  }
816
1138
 
817
1139
  function getUserImageSrc(user: Record<string, any>) {
818
1140
  const hidUserId = toHidNumericId(user?.hidUserId);
819
- return user?.metadata?.photo || (hidUserId ? userImages.value[String(hidUserId)] : "") || "";
1141
+ return (
1142
+ user?.metadata?.photo ||
1143
+ (hidUserId ? userImages.value[String(hidUserId)] : "") ||
1144
+ ""
1145
+ );
820
1146
  }
821
1147
 
822
1148
  async function getNextUid() {
823
1149
  const readerIds = await getExistingHidReaderIds();
824
1150
  const maxNumber = users.value.reduce((max, user) => {
825
1151
  const match = String(user.hidUserId ?? "").match(/^UID(\d+)$/i);
826
- return Math.max(max, match ? Number(match[1]) : toHidNumericId(user.hidUserId) || 0);
1152
+ return Math.max(
1153
+ max,
1154
+ match ? Number(match[1]) : toHidNumericId(user.hidUserId) || 0
1155
+ );
827
1156
  }, 0);
828
1157
  const nextNumber = Math.max(maxNumber, ...readerIds, 0) + 1;
829
1158
  return `UID${String(nextNumber).padStart(6, "0")}`;
@@ -837,10 +1166,15 @@ async function getNextRegistration() {
837
1166
  const numericRegistrations = registrations
838
1167
  .map((registration) => {
839
1168
  const match = registration.match(/^(\d+)$/);
840
- return match ? { value: Number(match[1]), width: match[1].length } : undefined;
1169
+ return match
1170
+ ? { value: Number(match[1]), width: match[1].length }
1171
+ : undefined;
841
1172
  })
842
1173
  .filter((item): item is { value: number; width: number } => Boolean(item));
843
- const maxRegistration = numericRegistrations.reduce((max, item) => Math.max(max, item.value), 0);
1174
+ const maxRegistration = numericRegistrations.reduce(
1175
+ (max, item) => Math.max(max, item.value),
1176
+ 0
1177
+ );
844
1178
  const width = Math.max(6, ...numericRegistrations.map((item) => item.width));
845
1179
 
846
1180
  return String(maxRegistration + 1).padStart(width, "0");
@@ -882,11 +1216,14 @@ function normalizeHidUsers(response: Record<string, any>) {
882
1216
  hidUserId: String(user.id || ""),
883
1217
  rawHidUserId: user.id,
884
1218
  registration: user.registration,
1219
+ userTypeId: user.user_type_id,
885
1220
  cardNo: user.card_value || user.cardNo || "",
886
1221
  name: user.name,
887
1222
  metadata: {
888
1223
  name: user.name,
889
- imageTimestamp: user.image_timestamp ? String(user.image_timestamp) : "",
1224
+ imageTimestamp: user.image_timestamp
1225
+ ? String(user.image_timestamp)
1226
+ : "",
890
1227
  lastAccessAt: fromUnixSeconds(user.last_access),
891
1228
  hidReaderSource: true,
892
1229
  },
@@ -894,7 +1231,10 @@ function normalizeHidUsers(response: Record<string, any>) {
894
1231
  : [];
895
1232
  }
896
1233
 
897
- function mergeReaderUsersWithIdentities(readerUsers: Record<string, any>[], identities: Record<string, any>[]) {
1234
+ function mergeReaderUsersWithIdentities(
1235
+ readerUsers: Record<string, any>[],
1236
+ identities: Record<string, any>[]
1237
+ ) {
898
1238
  const identityById = new Map<string, Record<string, any>>();
899
1239
  identities.forEach((identity) => {
900
1240
  const id = toHidNumericId(identity.hidUserId);
@@ -902,7 +1242,9 @@ function mergeReaderUsersWithIdentities(readerUsers: Record<string, any>[], iden
902
1242
  });
903
1243
 
904
1244
  const merged = readerUsers.map((readerUser) => {
905
- const identity = identityById.get(String(toHidNumericId(readerUser.hidUserId)));
1245
+ const identity = identityById.get(
1246
+ String(toHidNumericId(readerUser.hidUserId))
1247
+ );
906
1248
  if (!identity) return readerUser;
907
1249
 
908
1250
  return {
@@ -917,8 +1259,11 @@ function mergeReaderUsersWithIdentities(readerUsers: Record<string, any>[], iden
917
1259
  metadata: {
918
1260
  ...readerUser.metadata,
919
1261
  ...identity.metadata,
920
- imageTimestamp: identity.metadata?.imageTimestamp || readerUser.metadata?.imageTimestamp,
921
- lastAccessAt: identity.metadata?.lastAccessAt || readerUser.metadata?.lastAccessAt,
1262
+ imageTimestamp:
1263
+ identity.metadata?.imageTimestamp ||
1264
+ readerUser.metadata?.imageTimestamp,
1265
+ lastAccessAt:
1266
+ identity.metadata?.lastAccessAt || readerUser.metadata?.lastAccessAt,
922
1267
  },
923
1268
  };
924
1269
  });
@@ -929,13 +1274,16 @@ function mergeReaderUsersWithIdentities(readerUsers: Record<string, any>[], iden
929
1274
  function filterUsers(items: Record<string, any>[]) {
930
1275
  const searchText = search.value.trim().toLowerCase();
931
1276
  return items.filter((item) => {
932
- const matchesSearch = !searchText || [
933
- getName(item),
934
- item.hidUserId,
935
- item.registration,
936
- item.cardNo,
937
- ].some((value) => String(value || "").toLowerCase().includes(searchText));
938
- const matchesStatus = status.value === "Status" || getMappingStatus(item) === status.value;
1277
+ const matchesSearch =
1278
+ !searchText ||
1279
+ [getName(item), item.hidUserId, item.registration, item.cardNo].some(
1280
+ (value) =>
1281
+ String(value || "")
1282
+ .toLowerCase()
1283
+ .includes(searchText)
1284
+ );
1285
+ const matchesStatus =
1286
+ status.value === "Status" || getMappingStatus(item) === status.value;
939
1287
  return matchesSearch && matchesStatus;
940
1288
  });
941
1289
  }
@@ -995,7 +1343,7 @@ function getHidErrorMessage(error: any) {
995
1343
  data?.statusMessage ||
996
1344
  error?.statusMessage ||
997
1345
  error?.message ||
998
- "Unable to save HID user.",
1346
+ "Unable to save HID user."
999
1347
  );
1000
1348
  }
1001
1349
  </script>