@7365admin1/layer-common 3.2.2-staging.181 → 3.2.2-staging.183
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/css/primitives.css +33 -14
- package/components/AppSelect.vue +80 -36
- package/components/HidAccessLogDashboard.vue +24 -9
- package/components/HidEnabledGate.vue +5 -1
- package/components/HidIntercomManagement.vue +172 -26
- package/components/HidQrCodeConfiguration.vue +17 -2
- package/components/HidReaderManagement.vue +13 -4
- package/components/HidUserEnrollment.vue +549 -33
- package/components/OnlineFormConfigurationForm.vue +82 -13
- package/components/OnlineFormFill.vue +14 -4
- package/composables/useHidAmico.ts +71 -0
- package/composables/useHidNavigation.ts +2 -2
- package/package.json +1 -1
- package/pages/[org]/[site]/access-mgmt/administrator/index.vue +2 -2
- package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +23 -0
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
</v-alert>
|
|
37
37
|
|
|
38
38
|
<TableMain
|
|
39
|
-
title="
|
|
40
|
-
:headers="
|
|
39
|
+
:title="tableTitle"
|
|
40
|
+
:headers="tableHeaders"
|
|
41
41
|
:items="users"
|
|
42
42
|
:loading="loading"
|
|
43
43
|
:items-per-page="-1"
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
@refresh="loadUsers"
|
|
50
50
|
@update:page="goToPage"
|
|
51
51
|
>
|
|
52
|
-
<template #actions>
|
|
52
|
+
<template v-if="!props.cardManagement" #actions>
|
|
53
53
|
<AppButton icon="mdi-account-plus" @click="openEnroll">Enroll</AppButton>
|
|
54
54
|
</template>
|
|
55
55
|
|
|
@@ -71,8 +71,11 @@
|
|
|
71
71
|
@keyup.enter="loadUsers"
|
|
72
72
|
/>
|
|
73
73
|
<AppSelect
|
|
74
|
+
v-if="!props.cardManagement"
|
|
74
75
|
v-model="status"
|
|
75
76
|
:items="statusOptions"
|
|
77
|
+
placeholder="Status"
|
|
78
|
+
clearable
|
|
76
79
|
@update:model-value="loadUsers"
|
|
77
80
|
/>
|
|
78
81
|
</div>
|
|
@@ -107,8 +110,43 @@
|
|
|
107
110
|
<StatusChip :status="getMappingStatus(item)" />
|
|
108
111
|
</template>
|
|
109
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
|
+
<template #[`item.cards`]="{ item }">
|
|
128
|
+
<div v-if="getUserPhysicalCards(item).length" class="hid-card-cell">
|
|
129
|
+
<span
|
|
130
|
+
v-for="card in getUserPhysicalCards(item)"
|
|
131
|
+
:key="card.id"
|
|
132
|
+
class="hid-card-cell__item"
|
|
133
|
+
>
|
|
134
|
+
{{ formatPhysicalCard(card) }}
|
|
135
|
+
</span>
|
|
136
|
+
</div>
|
|
137
|
+
<span v-else class="app-cell--muted">No card</span>
|
|
138
|
+
</template>
|
|
139
|
+
|
|
110
140
|
<template #[`item.action-table`]="{ item }">
|
|
111
|
-
<
|
|
141
|
+
<AppButton
|
|
142
|
+
v-if="props.cardManagement"
|
|
143
|
+
variant="secondary"
|
|
144
|
+
icon="mdi-card-account-details-outline"
|
|
145
|
+
@click="openCardManager(item)"
|
|
146
|
+
>
|
|
147
|
+
Manage
|
|
148
|
+
</AppButton>
|
|
149
|
+
<v-menu v-else>
|
|
112
150
|
<template #activator="{ props: menuProps }">
|
|
113
151
|
<AppButton v-bind="menuProps" variant="row" icon="mdi-dots-vertical" />
|
|
114
152
|
</template>
|
|
@@ -116,6 +154,7 @@
|
|
|
116
154
|
<v-list density="compact" min-width="150">
|
|
117
155
|
<v-list-item title="View" @click="openView(item)" />
|
|
118
156
|
<v-list-item title="Edit" @click="openEdit(item)" />
|
|
157
|
+
<v-list-item title="Manage HID Cards" @click="openCardManager(item)" />
|
|
119
158
|
<v-list-item
|
|
120
159
|
title="Delete"
|
|
121
160
|
class="text-error"
|
|
@@ -130,8 +169,13 @@
|
|
|
130
169
|
that nobody has been enrolled yet. -->
|
|
131
170
|
<template #no-data>
|
|
132
171
|
<div class="table-card__empty">
|
|
133
|
-
<v-icon
|
|
134
|
-
|
|
172
|
+
<v-icon
|
|
173
|
+
:icon="props.cardManagement ? 'mdi-card-account-details-outline' : 'mdi-account-off-outline'"
|
|
174
|
+
size="32"
|
|
175
|
+
/>
|
|
176
|
+
<span>
|
|
177
|
+
{{ props.cardManagement ? "No HID users available for card assignment." : "No HID users enrolled yet." }}
|
|
178
|
+
</span>
|
|
135
179
|
</div>
|
|
136
180
|
</template>
|
|
137
181
|
</TableMain>
|
|
@@ -207,6 +251,17 @@
|
|
|
207
251
|
klass="mb-3"
|
|
208
252
|
/>
|
|
209
253
|
|
|
254
|
+
<v-checkbox
|
|
255
|
+
v-model="form.isAdministrator"
|
|
256
|
+
label="Administrator"
|
|
257
|
+
density="compact"
|
|
258
|
+
hide-details
|
|
259
|
+
class="admin-rule-checkbox"
|
|
260
|
+
/>
|
|
261
|
+
<p class="field-hint admin-rule-hint">
|
|
262
|
+
Allow this HID user to access the reader as an administrator.
|
|
263
|
+
</p>
|
|
264
|
+
|
|
210
265
|
<div class="field-label">Access PIN (optional)</div>
|
|
211
266
|
<!--
|
|
212
267
|
The handoff draws no password field anywhere (the sign-in screens
|
|
@@ -294,6 +349,17 @@
|
|
|
294
349
|
><strong>{{
|
|
295
350
|
selectedUser ? getMappingStatus(selectedUser) : "N/A"
|
|
296
351
|
}}</strong>
|
|
352
|
+
<span>Administrator</span>
|
|
353
|
+
<strong class="admin-rule-detail">
|
|
354
|
+
<v-icon
|
|
355
|
+
:icon="selectedUser?.isAdministrator ? 'mdi-check-circle' : 'mdi-close-circle'"
|
|
356
|
+
:class="selectedUser?.isAdministrator
|
|
357
|
+
? 'admin-rule-indicator--enabled'
|
|
358
|
+
: 'admin-rule-indicator--disabled'"
|
|
359
|
+
size="18"
|
|
360
|
+
/>
|
|
361
|
+
{{ selectedUser?.isAdministrator ? "Yes" : "No" }}
|
|
362
|
+
</strong>
|
|
297
363
|
<span>Access PIN</span
|
|
298
364
|
><strong>{{ selectedUser?.metadata?.pinEnrolled ? "Configured" : "N/A" }}</strong>
|
|
299
365
|
</div>
|
|
@@ -305,6 +371,118 @@
|
|
|
305
371
|
</v-card>
|
|
306
372
|
</v-dialog>
|
|
307
373
|
|
|
374
|
+
<v-dialog v-model="cardDialog" max-width="560" persistent>
|
|
375
|
+
<v-card rounded="lg">
|
|
376
|
+
<v-card-title class="small-title">Physical HID Cards</v-card-title>
|
|
377
|
+
<v-card-text>
|
|
378
|
+
<p class="hid-card-user">
|
|
379
|
+
{{ getName(cardUser) }}
|
|
380
|
+
<span>{{ cardUser ? formatHidUid(cardUser.hidUserId) : "" }}</span>
|
|
381
|
+
</p>
|
|
382
|
+
|
|
383
|
+
<v-progress-linear v-if="loadingCards" indeterminate class="mb-3" />
|
|
384
|
+
<div v-else-if="physicalCards.length" class="hid-card-list mb-4">
|
|
385
|
+
<div v-for="card in physicalCards" :key="card.id" class="hid-card-row">
|
|
386
|
+
<v-icon icon="mdi-card-account-details-outline" size="22" />
|
|
387
|
+
<div class="hid-card-row__content">
|
|
388
|
+
<strong>{{ formatPhysicalCard(card) }}</strong>
|
|
389
|
+
<span>{{ getPhysicalCardSubtitle(card) }}</span>
|
|
390
|
+
</div>
|
|
391
|
+
<AppButton
|
|
392
|
+
variant="row"
|
|
393
|
+
icon="mdi-delete-outline"
|
|
394
|
+
:disabled="deletingCardId === String(card.id)"
|
|
395
|
+
@click="removePhysicalCard(card)"
|
|
396
|
+
/>
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
<v-alert v-else type="info" variant="tonal" density="compact" class="mb-4">
|
|
400
|
+
No physical HID card is assigned to this user.
|
|
401
|
+
</v-alert>
|
|
402
|
+
|
|
403
|
+
<div class="field-label">Card data type</div>
|
|
404
|
+
<AppSelect
|
|
405
|
+
v-model="cardForm.cardType"
|
|
406
|
+
:items="physicalCardTypeOptions"
|
|
407
|
+
class="mb-3"
|
|
408
|
+
/>
|
|
409
|
+
<p class="field-hint hid-card-type-hint">
|
|
410
|
+
PACS uses Facility Code + Card Number. CSN uses the raw serial number read from the physical card.
|
|
411
|
+
</p>
|
|
412
|
+
<p class="field-hint hid-card-access-hint">
|
|
413
|
+
The card uses this HID user's existing portal and access rules; assigning a card does not grant new access.
|
|
414
|
+
</p>
|
|
415
|
+
|
|
416
|
+
<div v-if="cardForm.cardType === 'pacs'" class="hid-card-fields">
|
|
417
|
+
<div>
|
|
418
|
+
<div class="field-label">Facility Code</div>
|
|
419
|
+
<AppField
|
|
420
|
+
v-model="cardForm.facilityCode"
|
|
421
|
+
inputmode="numeric"
|
|
422
|
+
placeholder="e.g. 123"
|
|
423
|
+
/>
|
|
424
|
+
</div>
|
|
425
|
+
<div>
|
|
426
|
+
<div class="field-label">Card Number</div>
|
|
427
|
+
<AppField
|
|
428
|
+
v-model="cardForm.cardNumber"
|
|
429
|
+
inputmode="numeric"
|
|
430
|
+
placeholder="e.g. 45678"
|
|
431
|
+
/>
|
|
432
|
+
</div>
|
|
433
|
+
</div>
|
|
434
|
+
<template v-else>
|
|
435
|
+
<div class="field-label">CSN / Raw Card Value</div>
|
|
436
|
+
<AppField
|
|
437
|
+
v-model="cardForm.cardValue"
|
|
438
|
+
inputmode="numeric"
|
|
439
|
+
placeholder="Enter the decimal card value"
|
|
440
|
+
/>
|
|
441
|
+
</template>
|
|
442
|
+
|
|
443
|
+
<v-alert
|
|
444
|
+
v-if="enrollingCard"
|
|
445
|
+
type="info"
|
|
446
|
+
variant="tonal"
|
|
447
|
+
density="compact"
|
|
448
|
+
class="mt-4"
|
|
449
|
+
>
|
|
450
|
+
Present the physical card to the selected Amico reader. The request will time out after 30 seconds.
|
|
451
|
+
</v-alert>
|
|
452
|
+
|
|
453
|
+
<div class="hid-card-actions mt-4">
|
|
454
|
+
<AppButton
|
|
455
|
+
variant="secondary"
|
|
456
|
+
:disabled="savingCard || enrollingCard"
|
|
457
|
+
@click="assignPhysicalCard"
|
|
458
|
+
>
|
|
459
|
+
Assign Manually
|
|
460
|
+
</AppButton>
|
|
461
|
+
<AppButton
|
|
462
|
+
icon="mdi-contactless-payment"
|
|
463
|
+
:disabled="savingCard || enrollingCard"
|
|
464
|
+
@click="enrollPhysicalCard"
|
|
465
|
+
>
|
|
466
|
+
Read at Reader
|
|
467
|
+
</AppButton>
|
|
468
|
+
<AppButton
|
|
469
|
+
v-if="enrollingCard"
|
|
470
|
+
variant="ghost"
|
|
471
|
+
@click="cancelPhysicalCardEnrollment"
|
|
472
|
+
>
|
|
473
|
+
Cancel Reading
|
|
474
|
+
</AppButton>
|
|
475
|
+
</div>
|
|
476
|
+
</v-card-text>
|
|
477
|
+
<v-card-actions>
|
|
478
|
+
<v-spacer />
|
|
479
|
+
<AppButton variant="ghost" :disabled="enrollingCard" @click="cardDialog = false">
|
|
480
|
+
Close
|
|
481
|
+
</AppButton>
|
|
482
|
+
</v-card-actions>
|
|
483
|
+
</v-card>
|
|
484
|
+
</v-dialog>
|
|
485
|
+
|
|
308
486
|
<v-dialog v-model="deleteDialog" max-width="360" persistent>
|
|
309
487
|
<v-card rounded="lg">
|
|
310
488
|
<v-card-title class="small-title">Delete</v-card-title>
|
|
@@ -347,9 +525,9 @@ const props = defineProps({
|
|
|
347
525
|
type: String,
|
|
348
526
|
required: true,
|
|
349
527
|
},
|
|
350
|
-
|
|
351
|
-
type:
|
|
352
|
-
default:
|
|
528
|
+
cardManagement: {
|
|
529
|
+
type: Boolean,
|
|
530
|
+
default: false,
|
|
353
531
|
},
|
|
354
532
|
});
|
|
355
533
|
|
|
@@ -359,6 +537,11 @@ const {
|
|
|
359
537
|
getUserImage,
|
|
360
538
|
setUserImage,
|
|
361
539
|
deleteUserImage,
|
|
540
|
+
getUserCards,
|
|
541
|
+
assignUserCard,
|
|
542
|
+
enrollUserCard,
|
|
543
|
+
cancelUserCardEnrollment,
|
|
544
|
+
deleteUserCard,
|
|
362
545
|
getUserPinStatus,
|
|
363
546
|
setUserPin,
|
|
364
547
|
deleteUserPin,
|
|
@@ -371,9 +554,10 @@ const {
|
|
|
371
554
|
const readers = ref<Record<string, any>[]>([]);
|
|
372
555
|
const users = ref<Record<string, any>[]>([]);
|
|
373
556
|
const userImages = ref<Record<string, string>>({});
|
|
557
|
+
const administratorUserIds = ref<Set<number>>(new Set());
|
|
374
558
|
const selectedReaderId = ref("");
|
|
375
559
|
const search = ref("");
|
|
376
|
-
const status = ref("
|
|
560
|
+
const status = ref("");
|
|
377
561
|
const page = ref(1);
|
|
378
562
|
const limit = ref(10);
|
|
379
563
|
const pages = ref(1);
|
|
@@ -385,7 +569,16 @@ const deleting = ref(false);
|
|
|
385
569
|
const formDialog = ref(false);
|
|
386
570
|
const viewDialog = ref(false);
|
|
387
571
|
const deleteDialog = ref(false);
|
|
572
|
+
const cardDialog = ref(false);
|
|
388
573
|
const selectedUser = ref<Record<string, any> | null>(null);
|
|
574
|
+
const cardUser = ref<Record<string, any> | null>(null);
|
|
575
|
+
const physicalCards = ref<Record<string, any>[]>([]);
|
|
576
|
+
const physicalCardsByUserId = ref<Record<string, Record<string, any>[]>>({});
|
|
577
|
+
const loadingCards = ref(false);
|
|
578
|
+
const savingCard = ref(false);
|
|
579
|
+
const enrollingCard = ref(false);
|
|
580
|
+
const cardEnrollmentCancelled = ref(false);
|
|
581
|
+
const deletingCardId = ref("");
|
|
389
582
|
const showPin = ref(false);
|
|
390
583
|
const pinEnrolled = ref(false);
|
|
391
584
|
const photoInput = ref<HTMLInputElement | null>(null);
|
|
@@ -407,11 +600,24 @@ const form = reactive({
|
|
|
407
600
|
name: "",
|
|
408
601
|
hidUserId: "",
|
|
409
602
|
registration: "",
|
|
603
|
+
isAdministrator: false,
|
|
410
604
|
accessPin: "",
|
|
411
605
|
photoPreview: "",
|
|
412
606
|
});
|
|
413
607
|
|
|
414
|
-
const
|
|
608
|
+
const cardForm = reactive({
|
|
609
|
+
cardType: "pacs" as "pacs" | "csn",
|
|
610
|
+
facilityCode: "",
|
|
611
|
+
cardNumber: "",
|
|
612
|
+
cardValue: "",
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
const physicalCardTypeOptions = [
|
|
616
|
+
{ title: "PACS (Facility Code + Card Number)", value: "pacs" },
|
|
617
|
+
{ title: "CSN (Raw Card Serial Number)", value: "csn" },
|
|
618
|
+
];
|
|
619
|
+
|
|
620
|
+
const statusOptions = ["Mapped", "Unmapped"];
|
|
415
621
|
|
|
416
622
|
/**
|
|
417
623
|
* `sortable: false` keeps the previous behaviour - the raw `v-table` never
|
|
@@ -424,9 +630,21 @@ const userHeaders = [
|
|
|
424
630
|
{ title: "Facial Data", value: "facialData", sortable: false },
|
|
425
631
|
{ title: "UID", value: "hidUserId", sortable: false },
|
|
426
632
|
{ title: "Status", value: "status", sortable: false },
|
|
633
|
+
{ title: "Admin", value: "isAdministrator", sortable: false },
|
|
634
|
+
{ title: "", value: "action-table", sortable: false },
|
|
635
|
+
];
|
|
636
|
+
|
|
637
|
+
const cardHeaders = [
|
|
638
|
+
{ title: "Name", value: "name", sortable: false },
|
|
639
|
+
{ title: "UID", value: "hidUserId", sortable: false },
|
|
640
|
+
{ title: "Physical HID Cards", value: "cards", sortable: false },
|
|
641
|
+
{ title: "Admin", value: "isAdministrator", sortable: false },
|
|
427
642
|
{ title: "", value: "action-table", sortable: false },
|
|
428
643
|
];
|
|
429
644
|
|
|
645
|
+
const tableTitle = computed(() => props.cardManagement ? "HID Cards" : "HID Users");
|
|
646
|
+
const tableHeaders = computed(() => props.cardManagement ? cardHeaders : userHeaders);
|
|
647
|
+
|
|
430
648
|
/**
|
|
431
649
|
* The list is paged on the SERVER, so the shared pager only moves the cursor -
|
|
432
650
|
* the rows still come from `loadUsers`. This is the same pair of statements
|
|
@@ -479,16 +697,16 @@ async function loadReaders() {
|
|
|
479
697
|
async function loadUsers() {
|
|
480
698
|
if (!selectedReaderId.value) {
|
|
481
699
|
users.value = [];
|
|
700
|
+
administratorUserIds.value = new Set();
|
|
701
|
+
physicalCardsByUserId.value = {};
|
|
482
702
|
return;
|
|
483
703
|
}
|
|
484
704
|
|
|
485
705
|
loading.value = true;
|
|
486
706
|
try {
|
|
487
707
|
const identityQuery: Record<string, any> = {
|
|
488
|
-
page:
|
|
708
|
+
page: 1,
|
|
489
709
|
limit: 500,
|
|
490
|
-
type: props.identityType,
|
|
491
|
-
search: search.value,
|
|
492
710
|
};
|
|
493
711
|
|
|
494
712
|
const [identityResponse, readerResponse, roleResponse] = await Promise.all([
|
|
@@ -499,9 +717,7 @@ async function loadUsers() {
|
|
|
499
717
|
limit: 500,
|
|
500
718
|
offset: 0,
|
|
501
719
|
}),
|
|
502
|
-
|
|
503
|
-
? loadAdministratorRoles(selectedReaderId.value)
|
|
504
|
-
: Promise.resolve(null),
|
|
720
|
+
loadAdministratorRoles(selectedReaderId.value),
|
|
505
721
|
]);
|
|
506
722
|
|
|
507
723
|
const identities =
|
|
@@ -509,14 +725,16 @@ async function loadUsers() {
|
|
|
509
725
|
identityResponse?.data?.items ??
|
|
510
726
|
identityResponse?.data?.identities ??
|
|
511
727
|
[];
|
|
512
|
-
|
|
513
|
-
const readerUsers = normalizeHidUsers(readerResponse).filter(
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
728
|
+
administratorUserIds.value = getAdministratorRoleUserIds(roleResponse);
|
|
729
|
+
const readerUsers = normalizeHidUsers(readerResponse).filter(
|
|
730
|
+
(user) => Number(user.userTypeId) !== 1,
|
|
731
|
+
);
|
|
732
|
+
const mergedUsers = mergeReaderUsersWithIdentities(readerUsers, identities).map(
|
|
733
|
+
(user) => ({
|
|
734
|
+
...user,
|
|
735
|
+
isAdministrator: hasAdministratorRule(user),
|
|
736
|
+
}),
|
|
737
|
+
);
|
|
520
738
|
const filteredUsers = filterUsers(mergedUsers);
|
|
521
739
|
total.value = filteredUsers.length;
|
|
522
740
|
pages.value = Math.max(1, Math.ceil(total.value / limit.value));
|
|
@@ -524,11 +742,17 @@ async function loadUsers() {
|
|
|
524
742
|
const start = (page.value - 1) * limit.value;
|
|
525
743
|
users.value = filteredUsers.slice(start, start + limit.value);
|
|
526
744
|
serverPageRange.value = "";
|
|
527
|
-
|
|
745
|
+
if (props.cardManagement) {
|
|
746
|
+
await loadVisibleUserCards(users.value);
|
|
747
|
+
} else {
|
|
748
|
+
await loadUserImages(users.value);
|
|
749
|
+
}
|
|
528
750
|
} catch (error) {
|
|
529
751
|
console.error("Unable to load HID reader users:", error);
|
|
530
752
|
showToast("Unable to load HID users from reader.", "error");
|
|
531
753
|
users.value = [];
|
|
754
|
+
administratorUserIds.value = new Set();
|
|
755
|
+
physicalCardsByUserId.value = {};
|
|
532
756
|
total.value = 0;
|
|
533
757
|
pages.value = 1;
|
|
534
758
|
serverPageRange.value = "";
|
|
@@ -567,6 +791,7 @@ function resetForm() {
|
|
|
567
791
|
form.name = "";
|
|
568
792
|
form.hidUserId = "";
|
|
569
793
|
form.registration = "";
|
|
794
|
+
form.isAdministrator = false;
|
|
570
795
|
form.accessPin = "";
|
|
571
796
|
pinEnrolled.value = false;
|
|
572
797
|
showPin.value = false;
|
|
@@ -597,6 +822,7 @@ async function openEdit(user: Record<string, any>) {
|
|
|
597
822
|
form.name = getName(user);
|
|
598
823
|
form.hidUserId = formatHidUid(user.hidUserId);
|
|
599
824
|
form.registration = user.registration ?? "";
|
|
825
|
+
form.isAdministrator = hasAdministratorRule(user);
|
|
600
826
|
form.accessPin = "";
|
|
601
827
|
pinEnrolled.value = Boolean(user.metadata?.pinEnrolled);
|
|
602
828
|
removePinRequested.value = false;
|
|
@@ -722,7 +948,7 @@ async function saveUser() {
|
|
|
722
948
|
const payload = {
|
|
723
949
|
hidUserId: String(hidUser.id),
|
|
724
950
|
registration: form.registration,
|
|
725
|
-
type:
|
|
951
|
+
type: (selectedUser.value?.type || "resident") as
|
|
726
952
|
| "resident"
|
|
727
953
|
| "staff"
|
|
728
954
|
| "contractor"
|
|
@@ -741,12 +967,13 @@ async function saveUser() {
|
|
|
741
967
|
imageTimestamp: selectedUser.value?.metadata?.imageTimestamp || "",
|
|
742
968
|
facialScores: selectedUser.value?.metadata?.facialScores || {},
|
|
743
969
|
pinEnrolled: pinEnrolled.value,
|
|
970
|
+
hidPhysicalCards: selectedUser.value?.metadata?.hidPhysicalCards || [],
|
|
744
971
|
},
|
|
745
972
|
};
|
|
746
973
|
|
|
747
974
|
if (selectedUser.value) {
|
|
748
975
|
await updateHidUserOnReader(readerId, hidUser);
|
|
749
|
-
await syncHidUserRole(readerId, hidUser.id,
|
|
976
|
+
await syncHidUserRole(readerId, hidUser.id, form.isAdministrator);
|
|
750
977
|
const facialResult = await syncFacialImage(readerId, hidUser.id);
|
|
751
978
|
applyFacialResult(payload.metadata, facialResult);
|
|
752
979
|
payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
|
|
@@ -757,7 +984,7 @@ async function saveUser() {
|
|
|
757
984
|
}
|
|
758
985
|
} else {
|
|
759
986
|
await createHidUserOnReader(readerId, hidUser);
|
|
760
|
-
await syncHidUserRole(readerId, hidUser.id,
|
|
987
|
+
await syncHidUserRole(readerId, hidUser.id, form.isAdministrator);
|
|
761
988
|
const facialResult = await syncFacialImage(readerId, hidUser.id);
|
|
762
989
|
applyFacialResult(payload.metadata, facialResult);
|
|
763
990
|
payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
|
|
@@ -1122,8 +1349,182 @@ function toHidNumericId(value: unknown) {
|
|
|
1122
1349
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
1123
1350
|
}
|
|
1124
1351
|
|
|
1125
|
-
function
|
|
1126
|
-
|
|
1352
|
+
function hasAdministratorRule(user?: Record<string, any> | null) {
|
|
1353
|
+
const hidUserId = toHidNumericId(user?.hidUserId);
|
|
1354
|
+
return Boolean(hidUserId && administratorUserIds.value.has(hidUserId));
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
async function loadVisibleUserCards(items: Record<string, any>[]) {
|
|
1358
|
+
if (!selectedReaderId.value) return;
|
|
1359
|
+
|
|
1360
|
+
const entries = await Promise.all(
|
|
1361
|
+
items.map(async (user) => {
|
|
1362
|
+
const hidUserId = toHidNumericId(user.hidUserId);
|
|
1363
|
+
if (!hidUserId) return ["", []] as const;
|
|
1364
|
+
|
|
1365
|
+
try {
|
|
1366
|
+
const response = await getUserCards(selectedReaderId.value, hidUserId);
|
|
1367
|
+
return [String(hidUserId), response?.data?.cards || []] as const;
|
|
1368
|
+
} catch {
|
|
1369
|
+
return [String(hidUserId), []] as const;
|
|
1370
|
+
}
|
|
1371
|
+
}),
|
|
1372
|
+
);
|
|
1373
|
+
|
|
1374
|
+
physicalCardsByUserId.value = Object.fromEntries(
|
|
1375
|
+
entries.filter(([hidUserId]) => Boolean(hidUserId)),
|
|
1376
|
+
);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function getUserPhysicalCards(user: Record<string, any>) {
|
|
1380
|
+
const hidUserId = toHidNumericId(user.hidUserId);
|
|
1381
|
+
return hidUserId ? physicalCardsByUserId.value[String(hidUserId)] || [] : [];
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
async function openCardManager(user: Record<string, any>) {
|
|
1385
|
+
cardUser.value = user;
|
|
1386
|
+
cardForm.cardType = "pacs";
|
|
1387
|
+
cardForm.facilityCode = "";
|
|
1388
|
+
cardForm.cardNumber = "";
|
|
1389
|
+
cardForm.cardValue = "";
|
|
1390
|
+
physicalCards.value = [];
|
|
1391
|
+
cardDialog.value = true;
|
|
1392
|
+
await loadPhysicalCards();
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
function getCardReaderAndUser() {
|
|
1396
|
+
const readerId = String(cardUser.value?.reader || selectedReaderId.value || "");
|
|
1397
|
+
const hidUserId = toHidNumericId(cardUser.value?.hidUserId);
|
|
1398
|
+
return { readerId, hidUserId };
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
async function loadPhysicalCards() {
|
|
1402
|
+
const { readerId, hidUserId } = getCardReaderAndUser();
|
|
1403
|
+
if (!readerId || !hidUserId) return;
|
|
1404
|
+
|
|
1405
|
+
loadingCards.value = true;
|
|
1406
|
+
try {
|
|
1407
|
+
const response = await getUserCards(readerId, hidUserId);
|
|
1408
|
+
physicalCards.value = response?.data?.cards || [];
|
|
1409
|
+
} catch (error: unknown) {
|
|
1410
|
+
physicalCards.value = [];
|
|
1411
|
+
showToast(getHidErrorMessage(error), "error");
|
|
1412
|
+
} finally {
|
|
1413
|
+
loadingCards.value = false;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
function parsePhysicalCardInteger(value: string, label: string) {
|
|
1418
|
+
if (!/^\d+$/.test(value.trim())) {
|
|
1419
|
+
showToast(`${label} must contain numbers only.`, "error");
|
|
1420
|
+
return null;
|
|
1421
|
+
}
|
|
1422
|
+
const number = Number(value);
|
|
1423
|
+
if (!Number.isSafeInteger(number) || number < 0) {
|
|
1424
|
+
showToast(`${label} is outside the supported range.`, "error");
|
|
1425
|
+
return null;
|
|
1426
|
+
}
|
|
1427
|
+
return number;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
async function assignPhysicalCard() {
|
|
1431
|
+
const { readerId, hidUserId } = getCardReaderAndUser();
|
|
1432
|
+
if (!readerId || !hidUserId) return;
|
|
1433
|
+
|
|
1434
|
+
let payload:
|
|
1435
|
+
| { cardType: "pacs"; facilityCode: number; cardNumber: number }
|
|
1436
|
+
| { cardType: "csn"; cardValue: string };
|
|
1437
|
+
if (cardForm.cardType === "pacs") {
|
|
1438
|
+
const facilityCode = parsePhysicalCardInteger(cardForm.facilityCode, "Facility Code");
|
|
1439
|
+
const cardNumber = parsePhysicalCardInteger(cardForm.cardNumber, "Card Number");
|
|
1440
|
+
if (facilityCode === null || cardNumber === null) return;
|
|
1441
|
+
payload = { cardType: "pacs", facilityCode, cardNumber };
|
|
1442
|
+
} else {
|
|
1443
|
+
const cardValue = parsePhysicalCardInteger(cardForm.cardValue, "CSN");
|
|
1444
|
+
if (cardValue === null || cardValue <= 0) {
|
|
1445
|
+
if (cardValue === 0) showToast("CSN must be greater than zero.", "error");
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
payload = { cardType: "csn", cardValue: String(cardValue) };
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
savingCard.value = true;
|
|
1452
|
+
try {
|
|
1453
|
+
await assignUserCard(readerId, hidUserId, payload);
|
|
1454
|
+
cardForm.facilityCode = "";
|
|
1455
|
+
cardForm.cardNumber = "";
|
|
1456
|
+
cardForm.cardValue = "";
|
|
1457
|
+
await loadPhysicalCards();
|
|
1458
|
+
await loadUsers();
|
|
1459
|
+
showToast("Physical HID card assigned and verified on the reader.");
|
|
1460
|
+
} catch (error: unknown) {
|
|
1461
|
+
showToast(getHidErrorMessage(error), "error");
|
|
1462
|
+
} finally {
|
|
1463
|
+
savingCard.value = false;
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
async function enrollPhysicalCard() {
|
|
1468
|
+
const { readerId, hidUserId } = getCardReaderAndUser();
|
|
1469
|
+
if (!readerId || !hidUserId) return;
|
|
1470
|
+
|
|
1471
|
+
enrollingCard.value = true;
|
|
1472
|
+
cardEnrollmentCancelled.value = false;
|
|
1473
|
+
try {
|
|
1474
|
+
await enrollUserCard(readerId, hidUserId, cardForm.cardType, 30);
|
|
1475
|
+
await loadPhysicalCards();
|
|
1476
|
+
await loadUsers();
|
|
1477
|
+
showToast("Physical HID card read and enrolled on the reader.");
|
|
1478
|
+
} catch (error: unknown) {
|
|
1479
|
+
if (!cardEnrollmentCancelled.value) {
|
|
1480
|
+
showToast(getHidErrorMessage(error), "error");
|
|
1481
|
+
}
|
|
1482
|
+
} finally {
|
|
1483
|
+
enrollingCard.value = false;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
async function cancelPhysicalCardEnrollment() {
|
|
1488
|
+
const { readerId } = getCardReaderAndUser();
|
|
1489
|
+
if (!readerId) return;
|
|
1490
|
+
cardEnrollmentCancelled.value = true;
|
|
1491
|
+
try {
|
|
1492
|
+
await cancelUserCardEnrollment(readerId);
|
|
1493
|
+
showToast("Physical HID card reading cancelled.");
|
|
1494
|
+
} catch (error: unknown) {
|
|
1495
|
+
showToast(getHidErrorMessage(error), "error");
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
async function removePhysicalCard(card: Record<string, any>) {
|
|
1500
|
+
if (!window.confirm(`Remove physical HID card ${formatPhysicalCard(card)}?`)) return;
|
|
1501
|
+
const { readerId, hidUserId } = getCardReaderAndUser();
|
|
1502
|
+
if (!readerId || !hidUserId || !card?.id) return;
|
|
1503
|
+
|
|
1504
|
+
deletingCardId.value = String(card.id);
|
|
1505
|
+
try {
|
|
1506
|
+
await deleteUserCard(readerId, hidUserId, card.id);
|
|
1507
|
+
await loadPhysicalCards();
|
|
1508
|
+
await loadUsers();
|
|
1509
|
+
showToast("Physical HID card removed from the reader.");
|
|
1510
|
+
} catch (error: unknown) {
|
|
1511
|
+
showToast(getHidErrorMessage(error), "error");
|
|
1512
|
+
} finally {
|
|
1513
|
+
deletingCardId.value = "";
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function formatPhysicalCard(card: Record<string, any>) {
|
|
1518
|
+
if (card?.cardType === "pacs") {
|
|
1519
|
+
return `PACS ${card.facilityCode}.${card.cardNumber}`;
|
|
1520
|
+
}
|
|
1521
|
+
if (card?.cardType === "csn") return `CSN ${card.value}`;
|
|
1522
|
+
return `Card ${card?.value || "N/A"}`;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
function getPhysicalCardSubtitle(card: Record<string, any>) {
|
|
1526
|
+
const source = card?.source === "reader" ? "Read at reader" : card?.source === "manual" ? "Manual" : "On device";
|
|
1527
|
+
return `${source} - Raw value ${card?.value || "N/A"}`;
|
|
1127
1528
|
}
|
|
1128
1529
|
|
|
1129
1530
|
function buildUnitLabel() {
|
|
@@ -1299,8 +1700,7 @@ function filterUsers(items: Record<string, any>[]) {
|
|
|
1299
1700
|
.toLowerCase()
|
|
1300
1701
|
.includes(searchText)
|
|
1301
1702
|
);
|
|
1302
|
-
const matchesStatus =
|
|
1303
|
-
status.value === "Status" || getMappingStatus(item) === status.value;
|
|
1703
|
+
const matchesStatus = !status.value || getMappingStatus(item) === status.value;
|
|
1304
1704
|
return matchesSearch && matchesStatus;
|
|
1305
1705
|
});
|
|
1306
1706
|
}
|
|
@@ -1466,6 +1866,122 @@ function getHidErrorMessage(error: any) {
|
|
|
1466
1866
|
background: var(--hover);
|
|
1467
1867
|
}
|
|
1468
1868
|
|
|
1869
|
+
.admin-rule-indicator {
|
|
1870
|
+
display: inline-flex;
|
|
1871
|
+
vertical-align: middle;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
.hid-card-user {
|
|
1875
|
+
display: flex;
|
|
1876
|
+
align-items: center;
|
|
1877
|
+
justify-content: space-between;
|
|
1878
|
+
gap: 12px;
|
|
1879
|
+
margin: 0 0 14px;
|
|
1880
|
+
color: var(--text);
|
|
1881
|
+
font-weight: 700;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
.hid-card-user span,
|
|
1885
|
+
.hid-card-row__content span {
|
|
1886
|
+
color: var(--muted);
|
|
1887
|
+
font-size: 12px;
|
|
1888
|
+
font-weight: 600;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
.hid-card-cell {
|
|
1892
|
+
display: flex;
|
|
1893
|
+
flex-wrap: wrap;
|
|
1894
|
+
gap: 6px;
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
.hid-card-cell__item {
|
|
1898
|
+
padding: 4px 9px;
|
|
1899
|
+
border-radius: 999px;
|
|
1900
|
+
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
|
1901
|
+
color: var(--primary);
|
|
1902
|
+
font-size: 12px;
|
|
1903
|
+
font-weight: 700;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
.hid-card-list {
|
|
1907
|
+
overflow: hidden;
|
|
1908
|
+
border: 1px solid var(--border);
|
|
1909
|
+
border-radius: var(--r-inner);
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
.hid-card-row {
|
|
1913
|
+
display: flex;
|
|
1914
|
+
align-items: center;
|
|
1915
|
+
gap: 12px;
|
|
1916
|
+
min-height: 58px;
|
|
1917
|
+
padding: 8px 10px 8px 14px;
|
|
1918
|
+
color: var(--text2);
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
.hid-card-row + .hid-card-row {
|
|
1922
|
+
border-top: 1px solid var(--border);
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
.hid-card-row__content {
|
|
1926
|
+
display: flex;
|
|
1927
|
+
flex: 1;
|
|
1928
|
+
flex-direction: column;
|
|
1929
|
+
min-width: 0;
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
.hid-card-row__content strong {
|
|
1933
|
+
color: var(--text);
|
|
1934
|
+
font-size: 13px;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
.hid-card-fields {
|
|
1938
|
+
display: grid;
|
|
1939
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
1940
|
+
gap: 12px;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
.hid-card-type-hint {
|
|
1944
|
+
margin-bottom: 6px;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
.hid-card-access-hint {
|
|
1948
|
+
margin-bottom: 14px;
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
.hid-card-actions {
|
|
1952
|
+
display: flex;
|
|
1953
|
+
flex-wrap: wrap;
|
|
1954
|
+
gap: 8px;
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
@media (max-width: 520px) {
|
|
1958
|
+
.hid-card-fields {
|
|
1959
|
+
grid-template-columns: 1fr;
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
.admin-rule-indicator--enabled {
|
|
1964
|
+
color: var(--ok);
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
.admin-rule-indicator--disabled {
|
|
1968
|
+
color: var(--err);
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
.admin-rule-checkbox {
|
|
1972
|
+
margin-top: -4px;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
.admin-rule-hint {
|
|
1976
|
+
margin: -2px 0 14px;
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
.admin-rule-detail {
|
|
1980
|
+
display: inline-flex;
|
|
1981
|
+
align-items: center;
|
|
1982
|
+
gap: 6px;
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1469
1985
|
.hidden-input {
|
|
1470
1986
|
display: none;
|
|
1471
1987
|
}
|