@7365admin1/layer-common 4.0.3-staging.232 → 4.0.3-staging.234

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.
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <section class="hid-reader-user-roster">
3
3
  <TableMain
4
- title="HID Reader Users"
4
+ title="HID Users"
5
5
  :headers="headers"
6
6
  :items="users"
7
7
  :loading="loading"
@@ -14,12 +14,6 @@
14
14
  @refresh="loadUsers"
15
15
  @update:page="goToPage"
16
16
  >
17
- <template #actions>
18
- <AppButton icon="mdi-account-switch-outline" @click="openMapping">
19
- HID User Mapping
20
- </AppButton>
21
- </template>
22
-
23
17
  <template #extension>
24
18
  <div class="app-filter-row hid-reader-user-roster__filters">
25
19
  <AppSelect
@@ -35,6 +29,12 @@
35
29
  placeholder="Search Amico user, UID or registration"
36
30
  @keyup.enter="reloadFromFirstPage"
37
31
  />
32
+ <AppSelect
33
+ v-model="userType"
34
+ class="user-type-select"
35
+ :items="userTypeOptions"
36
+ @update:model-value="reloadFromFirstPage"
37
+ />
38
38
  </div>
39
39
  </template>
40
40
 
@@ -42,6 +42,12 @@
42
42
  <span class="app-cell--strong">{{ getRawName(item) }}</span>
43
43
  </template>
44
44
 
45
+ <template #[`item.userType`]="{ item }">
46
+ <span class="hid-user-type" :class="{ 'hid-user-type--visitor': isVisitor(item) }">
47
+ {{ getUserTypeLabel(item) }}
48
+ </span>
49
+ </template>
50
+
45
51
  <template #[`item.hidUserId`]="{ item }">
46
52
  <span class="app-cell--num">{{ formatUid(item.hidUserId) }}</span>
47
53
  </template>
@@ -68,17 +74,15 @@
68
74
  </span>
69
75
  </template>
70
76
 
71
- <template #[`item.isAdministrator`]="{ item }">
72
- <v-icon
73
- :icon="item.isAdministrator ? 'mdi-check-circle' : 'mdi-close-circle'"
74
- :class="item.isAdministrator ? 'admin-rule--enabled' : 'admin-rule--disabled'"
75
- :aria-label="item.isAdministrator ? 'Administrator' : 'Not administrator'"
76
- size="22"
77
- />
78
- </template>
79
-
80
- <template #[`item.mappingStatus`]="{ item }">
81
- <StatusChip :status="isMapped(item) ? 'Mapped' : 'Unmapped'" />
77
+ <template #[`item.action-table`]="{ item }">
78
+ <AppButton
79
+ variant="row"
80
+ icon="mdi-form-textbox-password"
81
+ aria-label="Update access PIN"
82
+ @click="openPinDialog(item)"
83
+ >
84
+ PIN
85
+ </AppButton>
82
86
  </template>
83
87
 
84
88
  <template #no-data>
@@ -89,6 +93,89 @@
89
93
  </template>
90
94
  </TableMain>
91
95
 
96
+ <v-dialog v-model="pinDialog" max-width="420" persistent>
97
+ <v-card rounded="lg">
98
+ <v-card-title class="small-title">Update Access PIN</v-card-title>
99
+ <v-card-text>
100
+ <p class="pin-user">
101
+ {{ pinUser ? getRawName(pinUser) : "HID User" }}
102
+ <span>{{ pinUser ? formatUid(pinUser.hidUserId) : "" }}</span>
103
+ </p>
104
+ <v-progress-linear v-if="pinLoading" indeterminate class="mb-4" />
105
+ <template v-else>
106
+ <p class="field-hint mb-4">
107
+ {{ pinEnrolled
108
+ ? "Enter a new PIN to replace the current one, or select Remove current PIN."
109
+ : "This numeric PIN is an independent Amico credential." }}
110
+ </p>
111
+ <div class="field-label">Access PIN</div>
112
+ <AppField
113
+ v-model="accessPin"
114
+ aria-label="Access PIN"
115
+ placeholder="Enter numeric access PIN"
116
+ inputmode="numeric"
117
+ autocomplete="new-password"
118
+ :disabled="removePinRequested"
119
+ :type="showPin ? 'text' : 'password'"
120
+ @update:model-value="onPinInput"
121
+ >
122
+ <template #trailing>
123
+ <button
124
+ type="button"
125
+ class="app-field__trailing"
126
+ :aria-label="showPin ? 'Hide access PIN' : 'Show access PIN'"
127
+ @click="showPin = !showPin"
128
+ >
129
+ <v-icon :icon="showPin ? 'mdi-eye-off' : 'mdi-eye'" size="18" />
130
+ </button>
131
+ </template>
132
+ </AppField>
133
+ <div class="field-label mt-3">Confirm Access PIN</div>
134
+ <AppField
135
+ v-model="confirmPin"
136
+ aria-label="Confirm access PIN"
137
+ placeholder="Re-enter numeric access PIN"
138
+ inputmode="numeric"
139
+ autocomplete="new-password"
140
+ :disabled="removePinRequested"
141
+ :type="showPin ? 'text' : 'password'"
142
+ @update:model-value="onConfirmPinInput"
143
+ />
144
+ <v-checkbox
145
+ v-if="pinEnrolled"
146
+ v-model="removePinRequested"
147
+ label="Remove current access PIN"
148
+ density="compact"
149
+ hide-details
150
+ class="mt-2"
151
+ />
152
+ <p v-if="pinError" class="pin-error">{{ pinError }}</p>
153
+ </template>
154
+ </v-card-text>
155
+ <v-card-actions class="pa-0">
156
+ <v-btn
157
+ class="text-none action-cancel"
158
+ variant="flat"
159
+ height="44"
160
+ :disabled="pinSaving"
161
+ @click="pinDialog = false"
162
+ >
163
+ Cancel
164
+ </v-btn>
165
+ <v-btn
166
+ class="text-none action-submit"
167
+ variant="flat"
168
+ height="44"
169
+ :disabled="pinLoading || pinSaving || (!removePinRequested && !accessPin)"
170
+ :loading="pinSaving"
171
+ @click="savePin"
172
+ >
173
+ {{ removePinRequested ? "Remove PIN" : pinEnrolled ? "Update PIN" : "Save PIN" }}
174
+ </v-btn>
175
+ </v-card-actions>
176
+ </v-card>
177
+ </v-dialog>
178
+
92
179
  <v-snackbar v-model="snackbar.show" :color="snackbar.color" timeout="3500">
93
180
  {{ snackbar.message }}
94
181
  </v-snackbar>
@@ -116,41 +203,57 @@ type HidReaderUser = Record<string, unknown> & {
116
203
  registration?: string;
117
204
  name?: string;
118
205
  metadata?: HidMetadata;
119
- person?: string;
120
- member?: string;
121
- serviceProvider?: string;
122
- visitor?: string;
123
- isAdministrator?: boolean;
124
206
  };
125
207
 
126
- const { getReaders, getReaderUsers, getUserImage } = useHidAmico();
127
- const route = useRoute();
128
- const router = useRouter();
208
+ const {
209
+ getReaders,
210
+ getReaderUsers,
211
+ getUserImage,
212
+ getUserPinStatus,
213
+ setUserPin,
214
+ deleteUserPin,
215
+ } = useHidAmico();
129
216
  const readers = ref<HidReader[]>([]);
130
217
  const users = ref<HidReaderUser[]>([]);
131
218
  const userImages = ref<Record<string, string>>({});
132
219
  const selectedReaderId = ref("");
133
220
  const search = ref("");
221
+ const userType = ref<"all" | "user" | "visitor">("all");
134
222
  const page = ref(1);
135
223
  const pages = ref(1);
136
224
  const total = ref(0);
137
225
  const serverPageRange = ref("");
138
226
  const loading = ref(false);
227
+ const pinDialog = ref(false);
228
+ const pinUser = ref<HidReaderUser>();
229
+ const accessPin = ref("");
230
+ const confirmPin = ref("");
231
+ const showPin = ref(false);
232
+ const pinEnrolled = ref(false);
233
+ const removePinRequested = ref(false);
234
+ const pinLoading = ref(false);
235
+ const pinSaving = ref(false);
236
+ const pinError = ref("");
139
237
  const snackbar = reactive({ show: false, color: "success", message: "" });
140
238
 
141
239
  const headers = [
142
240
  { title: "Amico Name", value: "name", sortable: false },
241
+ { title: "Type", value: "userType", sortable: false },
143
242
  { title: "UID", value: "hidUserId", sortable: false },
144
243
  { title: "Registration No.", value: "registration", sortable: false },
145
244
  { title: "Facial Data", value: "facialData", sortable: false },
146
- { title: "Admin", value: "isAdministrator", sortable: false },
147
- { title: "iService Mapping", value: "mappingStatus", sortable: false },
245
+ { title: "", value: "action-table", sortable: false },
148
246
  ];
149
247
 
150
248
  const readerOptions = computed(() => readers.value.map((reader) => ({
151
249
  title: `${reader.name || reader.deviceId || reader._id} - ${reader.portalName || "Portal not configured"}`,
152
250
  value: reader._id,
153
251
  })));
252
+ const userTypeOptions = [
253
+ { title: "All user types", value: "all" },
254
+ { title: "HID Users", value: "user" },
255
+ { title: "Visitors", value: "visitor" },
256
+ ];
154
257
 
155
258
  const pageRange = computed(() => {
156
259
  if (serverPageRange.value) return serverPageRange.value;
@@ -191,18 +294,50 @@ async function loadUsers() {
191
294
 
192
295
  loading.value = true;
193
296
  try {
194
- const response = toRecord(await getReaderUsers(selectedReaderId.value, {
195
- page: page.value,
196
- limit: 10,
197
- search: search.value.trim(),
198
- }));
297
+ let clientFiltersUserType = false;
298
+ let response: Record<string, unknown>;
299
+ try {
300
+ response = toRecord(await getReaderUsers(selectedReaderId.value, {
301
+ page: page.value,
302
+ limit: 10,
303
+ search: search.value.trim(),
304
+ includeVisitors: true,
305
+ userType: userType.value,
306
+ }));
307
+ } catch (error: unknown) {
308
+ if (!isUnsupportedUserTypeFilter(error)) throw error;
309
+
310
+ // A deployed API can lag behind the shared layer. Retain a working
311
+ // Visitor/HID User filter while that API has not yet added userType.
312
+ clientFiltersUserType = true;
313
+ response = toRecord(await getReaderUsers(selectedReaderId.value, {
314
+ page: 1,
315
+ limit: 100,
316
+ search: search.value.trim(),
317
+ includeVisitors: true,
318
+ }));
319
+ }
199
320
  const data = toRecord(response.data);
200
321
  const items = response.items ?? data.items ?? [];
201
- users.value = (Array.isArray(items) ? items : []).map(toReaderUser);
322
+ const allItems = Array.isArray(items) ? items : [];
323
+ const matchingItems = clientFiltersUserType
324
+ ? allItems.filter((item) => matchesUserType(toReaderUser(item)))
325
+ : allItems;
326
+ const pageStart = (page.value - 1) * 10;
327
+ const pageItems = clientFiltersUserType
328
+ ? matchingItems.slice(pageStart, pageStart + 10)
329
+ : matchingItems;
330
+ users.value = pageItems.map(toReaderUser);
202
331
  await loadUserImages(users.value);
203
- total.value = toNumber(response.total ?? data.total, users.value.length);
204
- pages.value = Math.max(1, toNumber(response.pages ?? data.pages, 1));
205
- serverPageRange.value = toText(response.pageRange ?? data.pageRange);
332
+ total.value = clientFiltersUserType
333
+ ? matchingItems.length
334
+ : toNumber(response.total ?? data.total, users.value.length);
335
+ pages.value = clientFiltersUserType
336
+ ? Math.max(1, Math.ceil(total.value / 10))
337
+ : Math.max(1, toNumber(response.pages ?? data.pages, 1));
338
+ serverPageRange.value = clientFiltersUserType
339
+ ? ""
340
+ : toText(response.pageRange ?? data.pageRange);
206
341
  } catch (error: unknown) {
207
342
  users.value = [];
208
343
  total.value = 0;
@@ -225,17 +360,6 @@ function reloadFromFirstPage() {
225
360
  loadUsers();
226
361
  }
227
362
 
228
- function openMapping() {
229
- router.push({
230
- name: "org-site-access-mgmt-hid-user-mapping",
231
- params: { org: route.params.org, site: props.site },
232
- });
233
- }
234
-
235
- function isMapped(user: HidReaderUser) {
236
- return Boolean(user.person || user.member || user.serviceProvider || user.visitor);
237
- }
238
-
239
363
  function hasFacialData(user: HidReaderUser) {
240
364
  return user.metadata?.facialEnrolled === true || Boolean(toText(user.metadata?.facialData));
241
365
  }
@@ -263,13 +387,97 @@ async function loadUserImages(items: HidReaderUser[]) {
263
387
 
264
388
  function getUserImageSrc(user: HidReaderUser) {
265
389
  const hidUserId = toHidNumericId(user.hidUserId);
266
- return toText(user.metadata?.photo) || (hidUserId ? userImages.value[String(hidUserId)] : "") || "";
390
+ return (hidUserId ? userImages.value[String(hidUserId)] : "") || "";
391
+ }
392
+
393
+ async function openPinDialog(user: HidReaderUser) {
394
+ const hidUserId = toHidNumericId(user.hidUserId);
395
+ if (!selectedReaderId.value || !hidUserId) {
396
+ showToast("This Amico user does not have a valid UID for PIN management.", "error");
397
+ return;
398
+ }
399
+
400
+ pinUser.value = user;
401
+ accessPin.value = "";
402
+ confirmPin.value = "";
403
+ showPin.value = false;
404
+ pinEnrolled.value = false;
405
+ removePinRequested.value = false;
406
+ pinError.value = "";
407
+ pinDialog.value = true;
408
+ pinLoading.value = true;
409
+ try {
410
+ const response = toRecord(await getUserPinStatus(selectedReaderId.value, hidUserId));
411
+ const data = toRecord(response.data ?? response);
412
+ pinEnrolled.value = data.pinEnrolled === true;
413
+ } catch (error: unknown) {
414
+ pinError.value = getErrorMessage(error, "Unable to load the current HID Access PIN.");
415
+ } finally {
416
+ pinLoading.value = false;
417
+ }
418
+ }
419
+
420
+ function onPinInput(value: string) {
421
+ accessPin.value = value.replace(/\D/g, "").slice(0, 32);
422
+ }
423
+
424
+ function onConfirmPinInput(value: string) {
425
+ confirmPin.value = value.replace(/\D/g, "").slice(0, 32);
426
+ }
427
+
428
+ async function savePin() {
429
+ const hidUserId = pinUser.value ? toHidNumericId(pinUser.value.hidUserId) : undefined;
430
+ if (!selectedReaderId.value || !hidUserId) return;
431
+
432
+ if (!removePinRequested.value) {
433
+ if (!/^\d{1,32}$/.test(accessPin.value)) {
434
+ pinError.value = "Access PIN must contain 1 to 32 numbers.";
435
+ return;
436
+ }
437
+ if (accessPin.value !== confirmPin.value) {
438
+ pinError.value = "Access PIN confirmation does not match.";
439
+ return;
440
+ }
441
+ }
442
+
443
+ pinSaving.value = true;
444
+ pinError.value = "";
445
+ try {
446
+ if (removePinRequested.value) {
447
+ await deleteUserPin(selectedReaderId.value, hidUserId);
448
+ pinEnrolled.value = false;
449
+ showToast("HID Access PIN removed.");
450
+ } else {
451
+ await setUserPin(selectedReaderId.value, hidUserId, accessPin.value);
452
+ pinEnrolled.value = true;
453
+ showToast("HID Access PIN updated.");
454
+ }
455
+ pinDialog.value = false;
456
+ } catch (error: unknown) {
457
+ pinError.value = getErrorMessage(error, "Unable to update HID Access PIN.");
458
+ } finally {
459
+ pinSaving.value = false;
460
+ }
267
461
  }
268
462
 
269
463
  function getRawName(user: HidReaderUser) {
270
464
  return toText(user.metadata?.name) || toText(user.name) || "Unknown";
271
465
  }
272
466
 
467
+ function isVisitor(user: HidReaderUser) {
468
+ return Number(user.metadata?.hidUserTypeId ?? user.user_type_id) === 1;
469
+ }
470
+
471
+ function getUserTypeLabel(user: HidReaderUser) {
472
+ return isVisitor(user) ? "Visitor" : "HID User";
473
+ }
474
+
475
+ function matchesUserType(user: HidReaderUser) {
476
+ return userType.value === "all"
477
+ || (userType.value === "visitor" && isVisitor(user))
478
+ || (userType.value === "user" && !isVisitor(user));
479
+ }
480
+
273
481
  function formatUid(value: unknown) {
274
482
  const text = toText(value);
275
483
  if (!text) return "N/A";
@@ -288,10 +496,16 @@ function showToast(message: string, color = "success") {
288
496
  snackbar.show = true;
289
497
  }
290
498
 
291
- function getErrorMessage(error: unknown) {
499
+ function getErrorMessage(error: unknown, fallback = "Unable to load HID reader users.") {
292
500
  const record = toRecord(error);
293
501
  const data = toRecord(record.data);
294
- return toText(data.message) || toText(record.message) || "Unable to load HID reader users.";
502
+ return toText(data.message) || toText(record.message) || fallback;
503
+ }
504
+
505
+ function isUnsupportedUserTypeFilter(error: unknown) {
506
+ const message = getErrorMessage(error, "").toLowerCase();
507
+ return message.includes("usertype")
508
+ && (message.includes("not allowed") || message.includes("unknown"));
295
509
  }
296
510
 
297
511
  function toReader(value: unknown): HidReader | null {
@@ -309,11 +523,6 @@ function toReaderUser(value: unknown): HidReaderUser {
309
523
  registration: toText(user.registration) || undefined,
310
524
  name: toText(user.name) || undefined,
311
525
  metadata: toMetadata(user.metadata),
312
- person: toText(user.person) || undefined,
313
- member: toText(user.member) || undefined,
314
- serviceProvider: toText(user.serviceProvider) || undefined,
315
- visitor: toText(user.visitor) || undefined,
316
- isAdministrator: user.isAdministrator === true,
317
526
  };
318
527
  }
319
528
 
@@ -354,12 +563,24 @@ function toNumber(value: unknown, fallback: number) {
354
563
  max-width: 320px;
355
564
  }
356
565
 
357
- .admin-rule--enabled {
358
- color: var(--success, #16855f);
566
+ .hid-reader-user-roster__filters :deep(.user-type-select) {
567
+ max-width: 190px;
568
+ }
569
+
570
+ .hid-user-type {
571
+ background: color-mix(in srgb, var(--primary, #3b70d1) 12%, transparent);
572
+ border-radius: 999px;
573
+ color: var(--primary, #3b70d1);
574
+ display: inline-flex;
575
+ font-size: 12px;
576
+ font-weight: 700;
577
+ padding: 4px 9px;
578
+ white-space: nowrap;
359
579
  }
360
580
 
361
- .admin-rule--disabled {
362
- color: var(--err, #d94a4a);
581
+ .hid-user-type--visitor {
582
+ background: color-mix(in srgb, var(--warning, #9a6b00) 14%, transparent);
583
+ color: var(--warning, #9a6b00);
363
584
  }
364
585
 
365
586
  .hid-face-avatar {
@@ -373,4 +594,26 @@ function toNumber(value: unknown, fallback: number) {
373
594
  gap: 6px;
374
595
  white-space: nowrap;
375
596
  }
597
+
598
+ .pin-user {
599
+ color: var(--text, #1e2632);
600
+ display: grid;
601
+ font-size: 16px;
602
+ font-weight: 700;
603
+ gap: 3px;
604
+ margin: 0 0 18px;
605
+ }
606
+
607
+ .pin-user span,
608
+ .field-hint {
609
+ color: var(--muted, #7a8494);
610
+ font-size: 13px;
611
+ font-weight: 500;
612
+ }
613
+
614
+ .pin-error {
615
+ color: var(--error, #d21f2b);
616
+ font-size: 13px;
617
+ margin: 12px 0 0;
618
+ }
376
619
  </style>
@@ -110,20 +110,6 @@
110
110
  <StatusChip :status="getMappingStatus(item)" />
111
111
  </template>
112
112
 
113
- <template #[`item.isAdministrator`]="{ item }">
114
- <v-icon
115
- :icon="item.isAdministrator ? 'mdi-check-circle' : 'mdi-close-circle'"
116
- :class="[
117
- 'admin-rule-indicator',
118
- item.isAdministrator
119
- ? 'admin-rule-indicator--enabled'
120
- : 'admin-rule-indicator--disabled',
121
- ]"
122
- :aria-label="item.isAdministrator ? 'Administrator' : 'Not administrator'"
123
- size="22"
124
- />
125
- </template>
126
-
127
113
  <template #[`item.cards`]="{ item }">
128
114
  <div v-if="getUserPhysicalCards(item).length" class="hid-card-cell">
129
115
  <span
@@ -134,6 +120,12 @@
134
120
  {{ formatPhysicalCard(card) }}
135
121
  </span>
136
122
  </div>
123
+ <span v-else-if="getUserCardLoadError(item)" class="hid-card-load-error">
124
+ Unable to load
125
+ <v-tooltip activator="parent" location="top">
126
+ {{ getUserCardLoadError(item) }}
127
+ </v-tooltip>
128
+ </span>
137
129
  <span v-else class="app-cell--muted">No card</span>
138
130
  </template>
139
131
 
@@ -269,17 +261,6 @@
269
261
  klass="mb-3"
270
262
  />
271
263
 
272
- <v-checkbox
273
- v-model="form.isAdministrator"
274
- label="Administrator"
275
- density="compact"
276
- hide-details
277
- class="admin-rule-checkbox"
278
- />
279
- <p class="field-hint admin-rule-hint">
280
- Allow this HID user to access the reader as an administrator.
281
- </p>
282
-
283
264
  <div class="field-label">Access PIN (optional)</div>
284
265
  <!--
285
266
  The handoff draws no password field anywhere (the sign-in screens
@@ -367,17 +348,6 @@
367
348
  ><strong>{{
368
349
  selectedUser ? getMappingStatus(selectedUser) : "N/A"
369
350
  }}</strong>
370
- <span>Administrator</span>
371
- <strong class="admin-rule-detail">
372
- <v-icon
373
- :icon="selectedUser?.isAdministrator ? 'mdi-check-circle' : 'mdi-close-circle'"
374
- :class="selectedUser?.isAdministrator
375
- ? 'admin-rule-indicator--enabled'
376
- : 'admin-rule-indicator--disabled'"
377
- size="18"
378
- />
379
- {{ selectedUser?.isAdministrator ? "Yes" : "No" }}
380
- </strong>
381
351
  <span>Access PIN</span
382
352
  ><strong>{{ selectedUser?.metadata?.pinEnrolled ? "Configured" : "N/A" }}</strong>
383
353
  </div>
@@ -668,6 +638,7 @@ const selectedUser = ref<HidUser | null>(null);
668
638
  const cardUser = ref<HidUser | null>(null);
669
639
  const physicalCards = ref<HidCard[]>([]);
670
640
  const physicalCardsByUserId = ref<Record<string, HidCard[]>>({});
641
+ const physicalCardErrorsByUserId = ref<Record<string, string>>({});
671
642
  const loadingCards = ref(false);
672
643
  const savingCard = ref(false);
673
644
  const enrollingCard = ref(false);
@@ -757,7 +728,6 @@ const userHeaders = [
757
728
  { title: "Facial Data", value: "facialData", sortable: false },
758
729
  { title: "UID", value: "hidUserId", sortable: false },
759
730
  { title: "Status", value: "status", sortable: false },
760
- { title: "Admin", value: "isAdministrator", sortable: false },
761
731
  { title: "", value: "action-table", sortable: false },
762
732
  ];
763
733
 
@@ -765,7 +735,6 @@ const cardHeaders = [
765
735
  { title: "Name", value: "name", sortable: false },
766
736
  { title: "UID", value: "hidUserId", sortable: false },
767
737
  { title: "Physical HID Cards", value: "cards", sortable: false },
768
- { title: "Admin", value: "isAdministrator", sortable: false },
769
738
  { title: "", value: "action-table", sortable: false },
770
739
  ];
771
740
 
@@ -835,6 +804,7 @@ async function loadUsers() {
835
804
  users.value = [];
836
805
  administratorUserIds.value = new Set();
837
806
  physicalCardsByUserId.value = {};
807
+ physicalCardErrorsByUserId.value = {};
838
808
  return;
839
809
  }
840
810
 
@@ -871,6 +841,7 @@ async function loadUsers() {
871
841
  users.value = [];
872
842
  administratorUserIds.value = new Set();
873
843
  physicalCardsByUserId.value = {};
844
+ physicalCardErrorsByUserId.value = {};
874
845
  total.value = 0;
875
846
  pages.value = 1;
876
847
  serverPageRange.value = "";
@@ -1110,12 +1081,11 @@ async function saveUser() {
1110
1081
  ? "resident"
1111
1082
  : form.subjectCategory === "service_provider"
1112
1083
  ? "contractor"
1113
- : form.isAdministrator ? "admin" : "staff") as
1084
+ : "staff") as
1114
1085
  | "resident"
1115
1086
  | "staff"
1116
1087
  | "contractor"
1117
1088
  | "visitor"
1118
- | "admin"
1119
1089
  | "unknown",
1120
1090
  status: "active" as const,
1121
1091
  metadata: {
@@ -1134,7 +1104,6 @@ async function saveUser() {
1134
1104
 
1135
1105
  if (selectedUser.value) {
1136
1106
  await updateHidUserOnReader(readerId, hidUser);
1137
- await syncHidUserRole(readerId, hidUser.id, form.isAdministrator);
1138
1107
  const facialResult = await syncFacialImage(readerId, hidUser.id);
1139
1108
  applyFacialResult(payload.metadata, facialResult);
1140
1109
  payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
@@ -1146,7 +1115,6 @@ async function saveUser() {
1146
1115
  } else {
1147
1116
  const created = await createHidUserOnReader(readerId, hidUser);
1148
1117
  if (created) newlyCreatedHidUserId = hidUser.id;
1149
- await syncHidUserRole(readerId, hidUser.id, form.isAdministrator);
1150
1118
  const facialResult = await syncFacialImage(readerId, hidUser.id);
1151
1119
  applyFacialResult(payload.metadata, facialResult);
1152
1120
  payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
@@ -1501,6 +1469,7 @@ function hasAdministratorRule(user?: HidUser | null) {
1501
1469
  async function loadVisibleUserCards(items: HidUser[]) {
1502
1470
  if (!selectedReaderId.value) return;
1503
1471
 
1472
+ const cardErrors: Record<string, string> = {};
1504
1473
  const entries = await Promise.all(
1505
1474
  items.map(async (user) => {
1506
1475
  const hidUserId = toHidNumericId(user.hidUserId);
@@ -1511,7 +1480,8 @@ async function loadVisibleUserCards(items: HidUser[]) {
1511
1480
  const responseData = toRecord(toRecord(response).data);
1512
1481
  const cards = responseData.cards;
1513
1482
  return [String(hidUserId), (Array.isArray(cards) ? cards : []).map(toHidCard)] as const;
1514
- } catch {
1483
+ } catch (error: unknown) {
1484
+ cardErrors[String(hidUserId)] = getHidErrorMessage(error);
1515
1485
  return [String(hidUserId), []] as const;
1516
1486
  }
1517
1487
  }),
@@ -1520,6 +1490,7 @@ async function loadVisibleUserCards(items: HidUser[]) {
1520
1490
  physicalCardsByUserId.value = Object.fromEntries(
1521
1491
  entries.filter(([hidUserId]) => Boolean(hidUserId)),
1522
1492
  );
1493
+ physicalCardErrorsByUserId.value = cardErrors;
1523
1494
  }
1524
1495
 
1525
1496
  function getUserPhysicalCards(user: HidUser) {
@@ -1527,6 +1498,11 @@ function getUserPhysicalCards(user: HidUser) {
1527
1498
  return hidUserId ? physicalCardsByUserId.value[String(hidUserId)] || [] : [];
1528
1499
  }
1529
1500
 
1501
+ function getUserCardLoadError(user: HidUser) {
1502
+ const hidUserId = toHidNumericId(user.hidUserId);
1503
+ return hidUserId ? physicalCardErrorsByUserId.value[String(hidUserId)] || "" : "";
1504
+ }
1505
+
1530
1506
  async function openCardManager(user: HidUser) {
1531
1507
  cardUser.value = user;
1532
1508
  const reader = readers.value.find((item) => String(item._id) === String(user.reader || selectedReaderId.value));
@@ -2082,6 +2058,13 @@ function toHidCard(value: unknown): HidCard {
2082
2058
  font-weight: 700;
2083
2059
  }
2084
2060
 
2061
+ .hid-card-load-error {
2062
+ color: var(--error, #d21f2b);
2063
+ cursor: help;
2064
+ font-size: 12px;
2065
+ font-weight: 700;
2066
+ }
2067
+
2085
2068
  .hid-card-list {
2086
2069
  overflow: hidden;
2087
2070
  border: 1px solid var(--border);