@7365admin1/layer-common 4.0.3-staging.232 → 4.0.3-staging.233
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/components/HidAccessLogDashboard.vue +16 -77
- package/components/HidQrCodeConfiguration.vue +37 -38
- package/components/HidReaderManagement.vue +18 -284
- package/components/HidReaderUserRoster.vue +303 -60
- package/components/HidUserEnrollment.vue +26 -43
- package/components/VisitorForm.vue +25 -10
- package/composables/useHidAmico.ts +1 -0
- package/composables/useHidNavigation.ts +0 -7
- package/package.json +1 -1
- package/components/HidUserMapping.vue +0 -583
- package/pages/[org]/[site]/access-mgmt/hid-user-mapping/index.vue +0 -23
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<section class="hid-reader-user-roster">
|
|
3
3
|
<TableMain
|
|
4
|
-
title="HID
|
|
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.
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 {
|
|
127
|
-
|
|
128
|
-
|
|
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: "
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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 =
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
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) ||
|
|
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
|
-
.
|
|
358
|
-
|
|
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
|
-
.
|
|
362
|
-
|
|
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
|
-
:
|
|
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);
|