@7365admin1/layer-common 4.96.0 → 4.97.1-staging.500
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/CHANGELOG.md +10 -4
- package/assets/css/primitives.css +57 -0
- package/assets/css/screens.css +8 -4
- package/components/AccessCardDetailsDialog.vue +59 -6
- package/components/AccessCardPreviewDialog.vue +371 -53
- package/components/AppSelect.vue +143 -4
- package/components/BuildingUnitFormEdit.vue +6 -0
- package/components/BuildingUnitManagement.vue +298 -0
- package/components/Dialog/UpdateMoreAction.vue +11 -1
- package/components/DocumentForm.vue +407 -112
- package/components/DocumentManagement.vue +213 -12
- package/components/Facility/BookingSetup.vue +63 -0
- package/components/HidAccessLogDashboard.vue +74 -17
- package/components/HidAccessPermissions.vue +424 -0
- package/components/HidIntercomManagement.vue +183 -14
- package/components/HidProfileQrCode.vue +332 -0
- package/components/HidQrCodeConfiguration.vue +17 -195
- package/components/HidReaderManagement.vue +398 -0
- package/components/HidReaderUserRoster.vue +44 -3
- package/components/HidUserEnrollment.vue +2239 -276
- package/components/InvitationClientForm.vue +19 -1
- package/components/Nfc/NFCPatrolRouteForm.vue +109 -58
- package/components/Nfc/NFCPatrolRouteMain.vue +3 -18
- package/components/Nfc/PatrolReport/PatrolActivityTable.vue +0 -8
- package/components/Nfc/PatrolReport/PatrolRouteSummary.vue +1 -1
- package/components/ServiceProviderMain.vue +48 -0
- package/components/SiteProviderChange.vue +371 -0
- package/components/TableMain.vue +22 -7
- package/components/VehicleQrStickerDialog.vue +64 -4
- package/components/VisitorForm.vue +12 -0
- package/components/VisitorManagement.vue +267 -5
- package/composables/useAccessManagement.ts +17 -1
- package/composables/useCustomerSite.ts +29 -0
- package/composables/useDocument.ts +7 -0
- package/composables/useFacility.ts +11 -0
- package/composables/useHidAmico.ts +165 -0
- package/composables/useHidNavigation.ts +25 -1
- package/composables/useHidReaderSelection.ts +50 -0
- package/composables/useMember.ts +12 -0
- package/composables/useNFCPatrolReport.ts +36 -32
- package/composables/useNFCPatrolReportFilters.ts +21 -3
- package/composables/useNFCPatrolRoute.ts +10 -3
- package/composables/usePeople.ts +19 -0
- package/composables/useServiceProvider.ts +45 -2
- package/composables/useSiteCategory.ts +45 -0
- package/package.json +1 -1
- package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
- package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
- package/types/document.d.ts +14 -0
- package/types/facility.d.ts +4 -0
- package/types/member.d.ts +4 -0
- package/types/nfc-patrol-report.ts +0 -2
- package/types/people.d.ts +28 -1
- package/types/service-provider.d.ts +5 -0
- package/types/site.d.ts +7 -1
- package/utils/hid-enrolment-subject.ts +344 -0
- package/utils/hid-permission-assignments.ts +198 -0
- package/utils/hid-reader-selection.ts +52 -0
- package/utils/nfc-patrol-time.ts +99 -0
- package/utils/occupancy-role.ts +109 -0
- package/utils/service-type.ts +30 -0
- package/utils/vehicle-qr-design.ts +304 -20
- package/utils/vehicle-qr-sticker.ts +126 -20
|
@@ -25,7 +25,11 @@
|
|
|
25
25
|
the old table card, so it stays above the shared one. -->
|
|
26
26
|
<v-alert
|
|
27
27
|
v-if="selectedReader"
|
|
28
|
-
:type="
|
|
28
|
+
:type="
|
|
29
|
+
selectedReader.portalId && selectedReader.portalName
|
|
30
|
+
? 'info'
|
|
31
|
+
: 'warning'
|
|
32
|
+
"
|
|
29
33
|
variant="tonal"
|
|
30
34
|
density="compact"
|
|
31
35
|
class="mb-4"
|
|
@@ -50,7 +54,9 @@
|
|
|
50
54
|
@update:page="goToPage"
|
|
51
55
|
>
|
|
52
56
|
<template v-if="!props.cardManagement" #actions>
|
|
53
|
-
<AppButton icon="mdi-account-plus" @click="openEnroll"
|
|
57
|
+
<AppButton icon="mdi-account-plus" @click="openEnroll"
|
|
58
|
+
>Enroll</AppButton
|
|
59
|
+
>
|
|
54
60
|
</template>
|
|
55
61
|
|
|
56
62
|
<!-- The design puts filters on their own full-width row under the
|
|
@@ -67,8 +73,8 @@
|
|
|
67
73
|
<AppField
|
|
68
74
|
v-model="search"
|
|
69
75
|
search
|
|
70
|
-
placeholder="Search"
|
|
71
|
-
@keyup.enter="
|
|
76
|
+
placeholder="Search name, UID or registration no."
|
|
77
|
+
@keyup.enter="onSearchSubmit"
|
|
72
78
|
/>
|
|
73
79
|
<AppSelect
|
|
74
80
|
v-if="!props.cardManagement"
|
|
@@ -93,7 +99,15 @@
|
|
|
93
99
|
</template>
|
|
94
100
|
|
|
95
101
|
<template #[`item.hidUserId`]="{ item }">
|
|
96
|
-
<span class="app-cell--num">{{
|
|
102
|
+
<span class="app-cell--num">{{
|
|
103
|
+
formatHidUid(item.hidUserId) || "N/A"
|
|
104
|
+
}}</span>
|
|
105
|
+
</template>
|
|
106
|
+
|
|
107
|
+
<!-- The reader allocates this alongside the UID. A row the reader holds
|
|
108
|
+
that we never enrolled has none, so it is not always present. -->
|
|
109
|
+
<template #[`item.registration`]="{ item }">
|
|
110
|
+
<span class="app-cell--num">{{ item.registration || "N/A" }}</span>
|
|
97
111
|
</template>
|
|
98
112
|
|
|
99
113
|
<!-- The shared chip. `utils/status.ts` now carries Mapped/Unmapped with
|
|
@@ -113,7 +127,10 @@
|
|
|
113
127
|
{{ formatPhysicalCard(card) }}
|
|
114
128
|
</span>
|
|
115
129
|
</div>
|
|
116
|
-
<span
|
|
130
|
+
<span
|
|
131
|
+
v-else-if="getUserCardLoadError(item)"
|
|
132
|
+
class="hid-card-load-error"
|
|
133
|
+
>
|
|
117
134
|
Unable to load
|
|
118
135
|
<v-tooltip activator="parent" location="top">
|
|
119
136
|
{{ getUserCardLoadError(item) }}
|
|
@@ -133,13 +150,20 @@
|
|
|
133
150
|
</AppButton>
|
|
134
151
|
<v-menu v-else>
|
|
135
152
|
<template #activator="{ props: menuProps }">
|
|
136
|
-
<AppButton
|
|
153
|
+
<AppButton
|
|
154
|
+
v-bind="menuProps"
|
|
155
|
+
variant="row"
|
|
156
|
+
icon="mdi-dots-vertical"
|
|
157
|
+
/>
|
|
137
158
|
</template>
|
|
138
159
|
|
|
139
160
|
<v-list density="compact" min-width="150">
|
|
140
161
|
<v-list-item title="View" @click="openView(item)" />
|
|
141
162
|
<v-list-item title="Edit" @click="openEdit(item)" />
|
|
142
|
-
<v-list-item
|
|
163
|
+
<v-list-item
|
|
164
|
+
title="Manage HID Cards"
|
|
165
|
+
@click="openCardManager(item)"
|
|
166
|
+
/>
|
|
143
167
|
<v-list-item
|
|
144
168
|
title="Delete"
|
|
145
169
|
class="text-error"
|
|
@@ -155,23 +179,31 @@
|
|
|
155
179
|
<template #no-data>
|
|
156
180
|
<div class="table-card__empty">
|
|
157
181
|
<v-icon
|
|
158
|
-
:icon="
|
|
182
|
+
:icon="
|
|
183
|
+
props.cardManagement
|
|
184
|
+
? 'mdi-card-account-details-outline'
|
|
185
|
+
: 'mdi-account-off-outline'
|
|
186
|
+
"
|
|
159
187
|
size="32"
|
|
160
188
|
/>
|
|
161
189
|
<span>
|
|
162
|
-
{{
|
|
190
|
+
{{
|
|
191
|
+
props.cardManagement
|
|
192
|
+
? "No HID users available for card assignment."
|
|
193
|
+
: "No HID users enrolled yet."
|
|
194
|
+
}}
|
|
163
195
|
</span>
|
|
164
196
|
</div>
|
|
165
197
|
</template>
|
|
166
198
|
</TableMain>
|
|
167
199
|
|
|
168
200
|
<v-dialog v-model="formDialog" max-width="420" persistent>
|
|
169
|
-
<v-card
|
|
170
|
-
<v-card-title
|
|
201
|
+
<v-card class="screen-modal">
|
|
202
|
+
<v-card-title>
|
|
171
203
|
{{ selectedUser ? "Edit User Information" : "Enroll" }}
|
|
172
204
|
</v-card-title>
|
|
173
205
|
|
|
174
|
-
<v-card-text>
|
|
206
|
+
<v-card-text class="screen-modal__body">
|
|
175
207
|
<div class="photo-wrap">
|
|
176
208
|
<v-menu>
|
|
177
209
|
<template #activator="{ props: menuProps }">
|
|
@@ -213,6 +245,15 @@
|
|
|
213
245
|
/>
|
|
214
246
|
</v-list>
|
|
215
247
|
</v-menu>
|
|
248
|
+
<!-- Sits under the button because it describes what the button
|
|
249
|
+
is reporting: the reader holds a face, and this is its
|
|
250
|
+
reference. Nothing to show while enrolling - the value does not
|
|
251
|
+
exist until the reader has accepted a photo. -->
|
|
252
|
+
<p v-if="editFacialData" class="photo-facial">
|
|
253
|
+
<span class="photo-facial__label">Facial Data</span>
|
|
254
|
+
<span class="photo-facial__value">{{ editFacialData }}</span>
|
|
255
|
+
</p>
|
|
256
|
+
|
|
216
257
|
<input
|
|
217
258
|
ref="photoInput"
|
|
218
259
|
type="file"
|
|
@@ -222,45 +263,252 @@
|
|
|
222
263
|
/>
|
|
223
264
|
</div>
|
|
224
265
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
266
|
+
<!-- WHO IS BEING ENROLLED.
|
|
267
|
+
The write path has always carried three subject categories -
|
|
268
|
+
`subjectLink` below maps them onto `person`, `member` or
|
|
269
|
+
`serviceProvider` - but the form only ever offered residents.
|
|
270
|
+
Staff hold no unit, so choosing Member replaces the whole
|
|
271
|
+
block/level/unit cascade with a single picker.
|
|
272
|
+
Enrolment only: an identity's subject cannot change category
|
|
273
|
+
after the fact, so an edit STATES the type rather than offering
|
|
274
|
+
it. -->
|
|
275
|
+
<div class="field-label">Enrolling <span>*</span></div>
|
|
234
276
|
<AppSelect
|
|
277
|
+
v-if="!selectedUser"
|
|
235
278
|
v-model="form.subjectCategory"
|
|
236
279
|
:items="subjectCategoryOptions"
|
|
237
|
-
placeholder="Select person type"
|
|
238
280
|
klass="mb-3"
|
|
239
281
|
@update:model-value="onSubjectCategoryChanged"
|
|
240
282
|
/>
|
|
283
|
+
<p v-else class="field-hint mb-3">{{ subjectCategoryLabel }}</p>
|
|
284
|
+
|
|
285
|
+
<!-- Block / Level / Unit / Resident. Enrolment only.
|
|
286
|
+
Enrollment is resident-only and the resident must already exist
|
|
287
|
+
in iService365 - you pick a person, you do not type one. Name is
|
|
288
|
+
therefore a mirror of that record, never an input, so the reader
|
|
289
|
+
can never end up holding a name the app does not know.
|
|
290
|
+
Every option is derived from the residents themselves, so a
|
|
291
|
+
block, level or unit only appears when somebody lives there.
|
|
292
|
+
The list loads on first touch of Block, not on dialog open.
|
|
293
|
+
Enrolment only, with no exception. Editing does not move
|
|
294
|
+
anybody: the record already carries its block, level and unit,
|
|
295
|
+
`openEdit` loads them into the form and `saveUser` writes them
|
|
296
|
+
back untouched, so the location survives an edit without being
|
|
297
|
+
asked for. A row that never had one keeps not having one; that
|
|
298
|
+
is not a reason to make somebody fill in a location before they
|
|
299
|
+
can change a PIN. -->
|
|
300
|
+
<template v-if="!selectedUser && isResidentSubject">
|
|
301
|
+
<div class="field-label">Block <span>*</span></div>
|
|
302
|
+
<!-- The listener sits on a wrapper, NOT on AppSelect: AppSelect
|
|
303
|
+
spreads `$attrs` over its v-menu activator AFTER the activator's
|
|
304
|
+
own bindings, so an `@click` here would replace the handler that
|
|
305
|
+
opens the dropdown. `focusin` covers keyboard users, and both
|
|
306
|
+
are idempotent - `ensureOccupancy()` fetches once.
|
|
307
|
+
AppSelect has no `loading` prop either; the placeholder carries
|
|
308
|
+
the loading and empty states instead. -->
|
|
309
|
+
<div @click="onBlockOpened" @focusin="onBlockOpened">
|
|
310
|
+
<AppSelect
|
|
311
|
+
v-model="form.block"
|
|
312
|
+
:items="blockOptions"
|
|
313
|
+
:placeholder="blockPlaceholder"
|
|
314
|
+
klass="mb-3"
|
|
315
|
+
@update:model-value="onBlockChanged"
|
|
316
|
+
/>
|
|
317
|
+
</div>
|
|
241
318
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
319
|
+
<div class="field-label">Level <span>*</span></div>
|
|
320
|
+
<AppSelect
|
|
321
|
+
v-model="form.level"
|
|
322
|
+
:items="levelOptions"
|
|
323
|
+
:disabled="!form.block"
|
|
324
|
+
placeholder="Select level"
|
|
325
|
+
klass="mb-3"
|
|
326
|
+
@update:model-value="onLevelChanged"
|
|
327
|
+
/>
|
|
328
|
+
|
|
329
|
+
<div class="field-label">Unit <span>*</span></div>
|
|
330
|
+
<AppSelect
|
|
331
|
+
v-model="form.unit"
|
|
332
|
+
:items="unitOptions"
|
|
333
|
+
:disabled="!form.level"
|
|
334
|
+
placeholder="Select unit"
|
|
335
|
+
klass="mb-3"
|
|
336
|
+
@update:model-value="onUnitChanged"
|
|
337
|
+
/>
|
|
338
|
+
</template>
|
|
339
|
+
|
|
340
|
+
<!-- Drawn only where it can offer something: while enrolling, or
|
|
341
|
+
while editing a record whose stored unit gives it a list. A row
|
|
342
|
+
with no unit has no candidates to show, and the dialog is then
|
|
343
|
+
purely the reader's own credentials - photo, PIN, password -
|
|
344
|
+
which is exactly the edit that must not be blocked. -->
|
|
345
|
+
<template v-if="isResidentSubject && (!selectedUser || form.unit)">
|
|
346
|
+
<div class="field-label">Resident <span>*</span></div>
|
|
347
|
+
<AppSelect
|
|
348
|
+
v-model="form.subjectId"
|
|
349
|
+
:items="subjectOptions"
|
|
350
|
+
:disabled="!form.unit"
|
|
351
|
+
:placeholder="unitResidentPlaceholder"
|
|
352
|
+
klass="mb-3"
|
|
353
|
+
@update:model-value="onResidentChanged"
|
|
354
|
+
/>
|
|
355
|
+
</template>
|
|
356
|
+
|
|
357
|
+
<!-- STAFF. One picker, because a member has no unit to narrow by.
|
|
358
|
+
The candidates are the permission subjects the Access Permissions
|
|
359
|
+
screen already reads - active members of this site or its
|
|
360
|
+
organisation, each carrying its role as a subtitle. Filtering is
|
|
361
|
+
AppSelect's own, over the loaded page, so typing costs no request.
|
|
362
|
+
A member with no app account is not in the list at all: reader
|
|
363
|
+
access is bound by user id, so there is nothing to bind. The note
|
|
364
|
+
below says how many were left out rather than letting a missing
|
|
365
|
+
colleague read as a bug. -->
|
|
366
|
+
<template v-if="!selectedUser && isMemberSubject">
|
|
367
|
+
<div class="field-label">Member <span>*</span></div>
|
|
368
|
+
<AppSelect
|
|
369
|
+
v-model="form.subjectId"
|
|
370
|
+
:items="memberOptions"
|
|
371
|
+
searchable
|
|
372
|
+
search-placeholder="Search staff by name or role"
|
|
373
|
+
:placeholder="memberPlaceholder"
|
|
374
|
+
:has-more="hasMoreMembers"
|
|
375
|
+
:loading-more="loadingMoreMembers"
|
|
376
|
+
klass="mb-3"
|
|
377
|
+
@update:model-value="onMemberChanged"
|
|
378
|
+
@load-more="loadMoreMembers"
|
|
379
|
+
/>
|
|
380
|
+
<!-- Ten at a time, because that page size shares the Members
|
|
381
|
+
menu's cache entry. The rest arrive as the list is scrolled, so
|
|
382
|
+
this only has to SAY where the list has got to - and warn that
|
|
383
|
+
the search box filters what is loaded, not the whole estate. -->
|
|
384
|
+
<p v-if="memberRangeNote" class="field-hint mb-3">
|
|
385
|
+
{{ memberRangeNote }}
|
|
386
|
+
</p>
|
|
387
|
+
<p v-if="hiddenMemberNote" class="field-hint mb-3">
|
|
388
|
+
{{ hiddenMemberNote }}
|
|
389
|
+
</p>
|
|
390
|
+
</template>
|
|
391
|
+
|
|
392
|
+
<!-- SERVICE PROVIDER STAFF. One person from a provider company, and
|
|
393
|
+
never the company: granting a company puts every employee of the
|
|
394
|
+
firm on the door, including the ones who do not attend this site.
|
|
395
|
+
The list, the service filter and the filter's "All services"
|
|
396
|
+
default are the Members > Service Providers screen's own, from the
|
|
397
|
+
same endpoint and through the same `filterProviderMembers` rule,
|
|
398
|
+
so the two screens cannot disagree about who works here.
|
|
399
|
+
Unpaged - the endpoint returns the site's people in one response -
|
|
400
|
+
so searching and filtering cost no request. -->
|
|
401
|
+
<template v-if="!selectedUser && isProviderSubject">
|
|
402
|
+
<div class="field-label">Service</div>
|
|
403
|
+
<AppSelect
|
|
404
|
+
v-model="providerService"
|
|
405
|
+
:items="providerServiceOptions"
|
|
406
|
+
klass="mb-3"
|
|
407
|
+
@update:model-value="onProviderServiceChanged"
|
|
408
|
+
/>
|
|
409
|
+
|
|
410
|
+
<div class="field-label">Person <span>*</span></div>
|
|
411
|
+
<AppSelect
|
|
412
|
+
v-model="form.subjectId"
|
|
413
|
+
:items="providerOptions"
|
|
414
|
+
searchable
|
|
415
|
+
search-placeholder="Search by name, company or role"
|
|
416
|
+
:placeholder="providerPlaceholder"
|
|
417
|
+
klass="mb-3"
|
|
418
|
+
@update:model-value="onProviderChanged"
|
|
419
|
+
/>
|
|
420
|
+
|
|
421
|
+
<!-- ACCESS ONLY, and the button says so. Shown here and nowhere
|
|
422
|
+
else: it is the one subject type that arrives a shift at a time,
|
|
423
|
+
and the people it adds fill in their own credentials from their
|
|
424
|
+
app afterwards. -->
|
|
425
|
+
<div class="hid-bulk-cta mb-3">
|
|
426
|
+
<AppButton
|
|
427
|
+
variant="ghost"
|
|
428
|
+
icon="mdi-account-multiple-plus-outline"
|
|
429
|
+
:disabled="loadingProviders"
|
|
430
|
+
@click="openBulkAdd"
|
|
431
|
+
>
|
|
432
|
+
Add Users in Bulk
|
|
433
|
+
</AppButton>
|
|
434
|
+
<span class="field-hint">
|
|
435
|
+
Gives access to many people at once. They add their own face, QR
|
|
436
|
+
or PIN from their app.
|
|
437
|
+
</span>
|
|
438
|
+
</div>
|
|
439
|
+
|
|
440
|
+
<p v-if="hiddenProviderNote" class="field-hint mb-3">
|
|
441
|
+
{{ hiddenProviderNote }}
|
|
442
|
+
</p>
|
|
443
|
+
</template>
|
|
444
|
+
|
|
445
|
+
<!-- The duplicate check. It is reader-scoped: this resident holding a
|
|
446
|
+
HID user on another gate is normal and says nothing here. -->
|
|
447
|
+
<p v-if="checkingResident" class="field-hint">
|
|
448
|
+
Checking enrollment...
|
|
449
|
+
</p>
|
|
450
|
+
<p
|
|
451
|
+
v-else-if="residentConflict"
|
|
452
|
+
class="field-hint field-hint--error mb-3"
|
|
453
|
+
>
|
|
454
|
+
{{ residentConflictMessage }}
|
|
455
|
+
</p>
|
|
456
|
+
<p
|
|
457
|
+
v-else-if="isResidentSubject && unitResidentNote"
|
|
458
|
+
class="field-hint mb-3"
|
|
459
|
+
>
|
|
460
|
+
{{ unitResidentNote }}
|
|
461
|
+
</p>
|
|
462
|
+
|
|
463
|
+
<!-- Read-only: it restates the chosen resident, it does not collect
|
|
464
|
+
anything. `selectedUser` is the edit case, where the name is
|
|
465
|
+
whatever the reader already holds. -->
|
|
466
|
+
<!-- A mirror of the chosen member's role, on the same rule as Name:
|
|
467
|
+
restated from the record, never collected. -->
|
|
468
|
+
<template v-if="!selectedUser && isPersonSubject">
|
|
469
|
+
<div class="field-label">{{ subjectMirrorLabel }}</div>
|
|
470
|
+
<AppField
|
|
471
|
+
v-model="form.subjectRole"
|
|
472
|
+
:aria-label="subjectMirrorLabel"
|
|
473
|
+
:placeholder="subjectMirrorPlaceholder"
|
|
474
|
+
readonly
|
|
475
|
+
klass="mb-3"
|
|
476
|
+
/>
|
|
477
|
+
</template>
|
|
478
|
+
|
|
479
|
+
<div class="field-label">Name</div>
|
|
480
|
+
<AppField
|
|
481
|
+
v-model="form.name"
|
|
482
|
+
aria-label="Name"
|
|
483
|
+
:placeholder="
|
|
484
|
+
isResidentSubject
|
|
485
|
+
? 'Select a resident above'
|
|
486
|
+
: isProviderSubject
|
|
487
|
+
? 'Select a person above'
|
|
488
|
+
: 'Select a member above'
|
|
489
|
+
"
|
|
490
|
+
readonly
|
|
248
491
|
klass="mb-3"
|
|
249
492
|
/>
|
|
250
493
|
|
|
251
494
|
<div class="field-label">ID</div>
|
|
252
|
-
<AppField
|
|
495
|
+
<AppField
|
|
496
|
+
v-model="form.hidUserId"
|
|
497
|
+
aria-label="ID"
|
|
498
|
+
readonly
|
|
499
|
+
klass="mb-3"
|
|
500
|
+
/>
|
|
253
501
|
|
|
254
502
|
<div class="field-label">Registration No. <span>*</span></div>
|
|
255
503
|
<AppField
|
|
256
504
|
v-model="form.registration"
|
|
257
505
|
aria-label="Registration No."
|
|
258
506
|
placeholder="Enter registration no."
|
|
259
|
-
|
|
507
|
+
readonly
|
|
260
508
|
klass="mb-3"
|
|
261
509
|
/>
|
|
262
510
|
|
|
263
|
-
<div class="field-label">
|
|
511
|
+
<div class="field-label">PIN (optional)</div>
|
|
264
512
|
<!--
|
|
265
513
|
The handoff draws no password field anywhere (the sign-in screens
|
|
266
514
|
are the owner's own), so the reveal toggle is this product's, not
|
|
@@ -270,8 +518,8 @@
|
|
|
270
518
|
-->
|
|
271
519
|
<AppField
|
|
272
520
|
v-model="form.accessPin"
|
|
273
|
-
aria-label="
|
|
274
|
-
placeholder="Enter
|
|
521
|
+
aria-label="PIN"
|
|
522
|
+
placeholder="Enter pin"
|
|
275
523
|
inputmode="numeric"
|
|
276
524
|
autocomplete="new-password"
|
|
277
525
|
:type="showPin ? 'text' : 'password'"
|
|
@@ -280,7 +528,7 @@
|
|
|
280
528
|
<button
|
|
281
529
|
type="button"
|
|
282
530
|
class="app-field__trailing"
|
|
283
|
-
:aria-label="showPin ? 'Hide
|
|
531
|
+
:aria-label="showPin ? 'Hide PIN' : 'Show PIN'"
|
|
284
532
|
@click="showPin = !showPin"
|
|
285
533
|
>
|
|
286
534
|
<v-icon :icon="showPin ? 'mdi-eye-off' : 'mdi-eye'" size="18" />
|
|
@@ -290,33 +538,82 @@
|
|
|
290
538
|
<!-- `persistent-hint` was Vuetify's; the design's settings module
|
|
291
539
|
draws an explanatory line as 12-13px/600 in `--muted`. -->
|
|
292
540
|
<p class="field-hint">
|
|
293
|
-
{{
|
|
541
|
+
{{
|
|
542
|
+
selectedUser && pinEnrolled
|
|
543
|
+
? "Leave blank to keep the current PIN."
|
|
544
|
+
: "PIN works as an alternative to face or QR."
|
|
545
|
+
}}
|
|
294
546
|
</p>
|
|
295
547
|
<v-checkbox
|
|
296
548
|
v-if="selectedUser && pinEnrolled"
|
|
297
549
|
v-model="removePinRequested"
|
|
298
|
-
label="Remove current
|
|
550
|
+
label="Remove current PIN"
|
|
551
|
+
density="compact"
|
|
552
|
+
hide-details
|
|
553
|
+
/>
|
|
554
|
+
|
|
555
|
+
<!-- Password: the reader stores only a hash plus its salt, so there
|
|
556
|
+
is nothing to read back and the field is always empty on open.
|
|
557
|
+
Same reveal toggle as the PIN above. -->
|
|
558
|
+
<div class="field-label mt-3">Password (optional)</div>
|
|
559
|
+
<AppField
|
|
560
|
+
v-model="form.password"
|
|
561
|
+
aria-label="Password"
|
|
562
|
+
:placeholder="
|
|
563
|
+
selectedUser && passwordSet
|
|
564
|
+
? 'Enter a new password'
|
|
565
|
+
: 'Enter password'
|
|
566
|
+
"
|
|
567
|
+
autocomplete="new-password"
|
|
568
|
+
:type="showPassword ? 'text' : 'password'"
|
|
569
|
+
>
|
|
570
|
+
<template #trailing>
|
|
571
|
+
<button
|
|
572
|
+
type="button"
|
|
573
|
+
class="app-field__trailing"
|
|
574
|
+
:aria-label="showPassword ? 'Hide password' : 'Show password'"
|
|
575
|
+
@click="showPassword = !showPassword"
|
|
576
|
+
>
|
|
577
|
+
<v-icon
|
|
578
|
+
:icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
|
|
579
|
+
size="18"
|
|
580
|
+
/>
|
|
581
|
+
</button>
|
|
582
|
+
</template>
|
|
583
|
+
</AppField>
|
|
584
|
+
<p class="field-hint">
|
|
585
|
+
{{
|
|
586
|
+
selectedUser && passwordSet
|
|
587
|
+
? "A password is set. Leave blank to keep it."
|
|
588
|
+
: "Used with the user ID on the reader keypad."
|
|
589
|
+
}}
|
|
590
|
+
</p>
|
|
591
|
+
<v-checkbox
|
|
592
|
+
v-if="selectedUser && passwordSet"
|
|
593
|
+
v-model="removePasswordRequested"
|
|
594
|
+
label="Remove current password"
|
|
299
595
|
density="compact"
|
|
300
596
|
hide-details
|
|
301
597
|
/>
|
|
302
598
|
</v-card-text>
|
|
303
599
|
|
|
304
|
-
<!-- The
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
600
|
+
<!-- The shared modal footer: `screen-btn-ghost` + `screen-btn-primary`
|
|
601
|
+
on `v-btn`, the same pair every other module's dialog uses
|
|
602
|
+
(`assets/css/screens.css`). `v-btn` stays because the submit needs
|
|
603
|
+
`:loading`, which `AppButton` does not carry. -->
|
|
604
|
+
<v-card-actions class="screen-modal__footer">
|
|
308
605
|
<v-btn
|
|
309
|
-
class="text-none
|
|
606
|
+
class="text-none screen-btn-ghost"
|
|
310
607
|
variant="flat"
|
|
311
|
-
height="44"
|
|
312
608
|
@click="formDialog = false"
|
|
313
|
-
>Cancel</v-btn
|
|
314
609
|
>
|
|
610
|
+
Cancel
|
|
611
|
+
</v-btn>
|
|
315
612
|
<v-btn
|
|
316
|
-
class="text-none
|
|
613
|
+
class="text-none screen-btn-primary"
|
|
317
614
|
variant="flat"
|
|
318
|
-
height="44"
|
|
319
615
|
:loading="saving"
|
|
616
|
+
:disabled="Boolean(residentConflict) || checkingResident"
|
|
320
617
|
@click="saveUser"
|
|
321
618
|
>
|
|
322
619
|
Save
|
|
@@ -325,12 +622,121 @@
|
|
|
325
622
|
</v-card>
|
|
326
623
|
</v-dialog>
|
|
327
624
|
|
|
625
|
+
<!-- ADD USERS IN BULK.
|
|
626
|
+
Over the enrolment form, not instead of it: the operator came here to
|
|
627
|
+
enrol, and this is the shortcut for the case where a whole shift arrives
|
|
628
|
+
at once. It grants ACCESS only - the people it adds are recognised by the
|
|
629
|
+
reader once they add a face, QR or PIN from their own app - which is why
|
|
630
|
+
it collects no photo, PIN or password.
|
|
631
|
+
The table is the same list as the picker behind it, under the same
|
|
632
|
+
service filter, with Company / Name / Service and nothing else: the
|
|
633
|
+
Members screen's other columns say nothing about whether to open a door
|
|
634
|
+
for somebody. -->
|
|
635
|
+
<v-dialog v-model="bulkDialog" max-width="640" persistent>
|
|
636
|
+
<v-card class="screen-modal">
|
|
637
|
+
<v-card-title>Add Users in Bulk</v-card-title>
|
|
638
|
+
<v-card-text class="screen-modal__body">
|
|
639
|
+
<p class="field-hint mb-3">
|
|
640
|
+
Gives these people access to
|
|
641
|
+
<strong>{{ bulkReaderLabel }}</strong>. They add their own face, QR
|
|
642
|
+
or PIN from their app - nothing is enrolled on the reader here.
|
|
643
|
+
</p>
|
|
644
|
+
|
|
645
|
+
<AppField
|
|
646
|
+
v-model="bulkSearch"
|
|
647
|
+
aria-label="Search people"
|
|
648
|
+
placeholder="Search by name, company or role"
|
|
649
|
+
klass="mb-3"
|
|
650
|
+
/>
|
|
651
|
+
|
|
652
|
+
<v-progress-linear v-if="bulkLoading" indeterminate class="mb-3" />
|
|
653
|
+
|
|
654
|
+
<template v-else-if="bulkRows.length">
|
|
655
|
+
<v-table density="compact" class="hid-bulk-table mb-2">
|
|
656
|
+
<thead>
|
|
657
|
+
<tr>
|
|
658
|
+
<th class="hid-bulk-table__check">
|
|
659
|
+
<!-- Acts on the rows in front of you - what the service
|
|
660
|
+
filter and the search have left - never on the whole
|
|
661
|
+
estate. -->
|
|
662
|
+
<v-checkbox
|
|
663
|
+
:model-value="bulkAllSelected"
|
|
664
|
+
:disabled="!bulkSelectableRows.length"
|
|
665
|
+
hide-details
|
|
666
|
+
density="compact"
|
|
667
|
+
aria-label="Select everyone listed"
|
|
668
|
+
@update:model-value="toggleBulkAll"
|
|
669
|
+
/>
|
|
670
|
+
</th>
|
|
671
|
+
<th>Company</th>
|
|
672
|
+
<th>Name</th>
|
|
673
|
+
<th>Service</th>
|
|
674
|
+
</tr>
|
|
675
|
+
</thead>
|
|
676
|
+
<tbody>
|
|
677
|
+
<tr v-for="row in bulkRows" :key="row.subjectId">
|
|
678
|
+
<td class="hid-bulk-table__check">
|
|
679
|
+
<!-- Already on the door: ticked and locked. Unticking here
|
|
680
|
+
would revoke access, and this dialog does not revoke. -->
|
|
681
|
+
<v-checkbox
|
|
682
|
+
:model-value="row.granted || isBulkSelected(row.subjectId)"
|
|
683
|
+
:disabled="row.granted"
|
|
684
|
+
hide-details
|
|
685
|
+
density="compact"
|
|
686
|
+
:aria-label="`Select ${row.name}`"
|
|
687
|
+
@update:model-value="toggleBulkRow(row.subjectId, $event)"
|
|
688
|
+
/>
|
|
689
|
+
</td>
|
|
690
|
+
<td>{{ row.company || "—" }}</td>
|
|
691
|
+
<td>
|
|
692
|
+
{{ row.name }}
|
|
693
|
+
<span v-if="row.granted" class="hid-bulk-granted">
|
|
694
|
+
Already has access
|
|
695
|
+
</span>
|
|
696
|
+
</td>
|
|
697
|
+
<td>{{ row.service || "—" }}</td>
|
|
698
|
+
</tr>
|
|
699
|
+
</tbody>
|
|
700
|
+
</v-table>
|
|
701
|
+
<p class="field-hint">
|
|
702
|
+
{{ bulkSelectedCount }} selected<template v-if="bulkGrantedCount">
|
|
703
|
+
· {{ bulkGrantedCount }} already have access</template
|
|
704
|
+
>
|
|
705
|
+
</p>
|
|
706
|
+
</template>
|
|
707
|
+
|
|
708
|
+
<v-alert v-else type="info" variant="tonal" density="compact">
|
|
709
|
+
{{ bulkEmptyText }}
|
|
710
|
+
</v-alert>
|
|
711
|
+
</v-card-text>
|
|
712
|
+
<v-card-actions class="screen-modal__footer">
|
|
713
|
+
<v-btn
|
|
714
|
+
class="text-none screen-btn-ghost"
|
|
715
|
+
variant="flat"
|
|
716
|
+
:disabled="bulkSaving"
|
|
717
|
+
@click="bulkDialog = false"
|
|
718
|
+
>
|
|
719
|
+
Cancel
|
|
720
|
+
</v-btn>
|
|
721
|
+
<v-btn
|
|
722
|
+
class="text-none screen-btn-primary"
|
|
723
|
+
variant="flat"
|
|
724
|
+
:loading="bulkSaving"
|
|
725
|
+
:disabled="!bulkSelectedCount"
|
|
726
|
+
@click="saveBulkAdd"
|
|
727
|
+
>
|
|
728
|
+
Give access
|
|
729
|
+
</v-btn>
|
|
730
|
+
</v-card-actions>
|
|
731
|
+
</v-card>
|
|
732
|
+
</v-dialog>
|
|
733
|
+
|
|
328
734
|
<HidCameraCaptureDialog v-model="cameraDialog" @capture="onCameraCapture" />
|
|
329
735
|
|
|
330
736
|
<v-dialog v-model="viewDialog" max-width="430">
|
|
331
|
-
<v-card
|
|
332
|
-
<v-card-title
|
|
333
|
-
<v-card-text>
|
|
737
|
+
<v-card class="screen-modal">
|
|
738
|
+
<v-card-title>User Information</v-card-title>
|
|
739
|
+
<v-card-text class="screen-modal__body">
|
|
334
740
|
<div class="detail-grid">
|
|
335
741
|
<span>Name</span><strong>{{ getName(selectedUser) }}</strong>
|
|
336
742
|
<span>User ID</span
|
|
@@ -347,21 +753,28 @@
|
|
|
347
753
|
><strong>{{
|
|
348
754
|
selectedUser ? getMappingStatus(selectedUser) : "N/A"
|
|
349
755
|
}}</strong>
|
|
350
|
-
<span>
|
|
351
|
-
><strong>{{
|
|
756
|
+
<span>PIN</span
|
|
757
|
+
><strong>{{
|
|
758
|
+
selectedUser?.metadata?.pinEnrolled ? "Configured" : "N/A"
|
|
759
|
+
}}</strong>
|
|
760
|
+
<span>Password</span
|
|
761
|
+
><strong>{{
|
|
762
|
+
selectedUser?.metadata?.passwordSet ? "Configured" : "N/A"
|
|
763
|
+
}}</strong>
|
|
352
764
|
</div>
|
|
353
765
|
</v-card-text>
|
|
354
|
-
<v-card-actions>
|
|
355
|
-
<
|
|
356
|
-
|
|
766
|
+
<v-card-actions class="screen-modal__footer">
|
|
767
|
+
<AppButton variant="ghost" @click="viewDialog = false"
|
|
768
|
+
>Close</AppButton
|
|
769
|
+
>
|
|
357
770
|
</v-card-actions>
|
|
358
771
|
</v-card>
|
|
359
772
|
</v-dialog>
|
|
360
773
|
|
|
361
774
|
<v-dialog v-model="cardDialog" max-width="560" persistent>
|
|
362
|
-
<v-card
|
|
363
|
-
<v-card-title
|
|
364
|
-
<v-card-text>
|
|
775
|
+
<v-card class="screen-modal">
|
|
776
|
+
<v-card-title>Physical HID Cards</v-card-title>
|
|
777
|
+
<v-card-text class="screen-modal__body">
|
|
365
778
|
<p class="hid-card-user">
|
|
366
779
|
{{ getName(cardUser) }}
|
|
367
780
|
<span>{{ cardUser ? formatHidUid(cardUser.hidUserId) : "" }}</span>
|
|
@@ -369,7 +782,11 @@
|
|
|
369
782
|
|
|
370
783
|
<v-progress-linear v-if="loadingCards" indeterminate class="mb-3" />
|
|
371
784
|
<div v-else-if="physicalCards.length" class="hid-card-list mb-4">
|
|
372
|
-
<div
|
|
785
|
+
<div
|
|
786
|
+
v-for="card in physicalCards"
|
|
787
|
+
:key="card.id"
|
|
788
|
+
class="hid-card-row"
|
|
789
|
+
>
|
|
373
790
|
<v-icon icon="mdi-card-account-details-outline" size="22" />
|
|
374
791
|
<div class="hid-card-row__content">
|
|
375
792
|
<strong>{{ formatPhysicalCard(card) }}</strong>
|
|
@@ -383,7 +800,13 @@
|
|
|
383
800
|
/>
|
|
384
801
|
</div>
|
|
385
802
|
</div>
|
|
386
|
-
<v-alert
|
|
803
|
+
<v-alert
|
|
804
|
+
v-else
|
|
805
|
+
type="info"
|
|
806
|
+
variant="tonal"
|
|
807
|
+
density="compact"
|
|
808
|
+
class="mb-4"
|
|
809
|
+
>
|
|
387
810
|
No physical HID card is assigned to this user.
|
|
388
811
|
</v-alert>
|
|
389
812
|
|
|
@@ -397,7 +820,8 @@
|
|
|
397
820
|
{{ cardProfileLabel }}
|
|
398
821
|
</v-alert>
|
|
399
822
|
<p class="field-hint hid-card-type-hint">
|
|
400
|
-
PACS uses Facility Code + Card Number. CSN uses the raw serial
|
|
823
|
+
PACS uses Facility Code + Card Number. CSN uses the raw serial
|
|
824
|
+
number read from the physical card.
|
|
401
825
|
</p>
|
|
402
826
|
<template v-if="hybridCardProfile">
|
|
403
827
|
<div class="field-label mt-3">Presented Card Type</div>
|
|
@@ -408,11 +832,13 @@
|
|
|
408
832
|
class="mb-3"
|
|
409
833
|
/>
|
|
410
834
|
<p class="field-hint hid-card-type-hint">
|
|
411
|
-
Choose MIFARE CSN for a MIFARE card. Choose PACS for any other
|
|
835
|
+
Choose MIFARE CSN for a MIFARE card. Choose PACS for any other
|
|
836
|
+
card in this reader mode.
|
|
412
837
|
</p>
|
|
413
838
|
</template>
|
|
414
839
|
<p class="field-hint hid-card-access-hint">
|
|
415
|
-
The card uses this HID user's existing portal and access rules;
|
|
840
|
+
The card uses this HID user's existing portal and access rules;
|
|
841
|
+
assigning a card does not grant new access.
|
|
416
842
|
</p>
|
|
417
843
|
|
|
418
844
|
<div v-if="cardForm.cardType === 'pacs'" class="hid-card-fields">
|
|
@@ -449,9 +875,15 @@
|
|
|
449
875
|
density="compact"
|
|
450
876
|
class="mt-4"
|
|
451
877
|
>
|
|
452
|
-
Present the physical card to the selected Amico reader. The request
|
|
878
|
+
Present the physical card to the selected Amico reader. The request
|
|
879
|
+
will time out after 30 seconds.
|
|
453
880
|
</v-alert>
|
|
454
881
|
|
|
882
|
+
<!-- Reading the card at the reader is the primary way in: it is the
|
|
883
|
+
only one that does not require knowing the facility code and card
|
|
884
|
+
number up front, which are printed on nothing the operator holds.
|
|
885
|
+
Assigning by hand stays for the cards whose values are already
|
|
886
|
+
known. -->
|
|
455
887
|
<div class="hid-card-actions mt-4">
|
|
456
888
|
<AppButton
|
|
457
889
|
variant="secondary"
|
|
@@ -470,15 +902,21 @@
|
|
|
470
902
|
<AppButton
|
|
471
903
|
v-if="enrollingCard"
|
|
472
904
|
variant="ghost"
|
|
905
|
+
:disabled="cancellingCard"
|
|
473
906
|
@click="cancelPhysicalCardEnrollment"
|
|
474
907
|
>
|
|
475
|
-
Cancel Reading
|
|
908
|
+
{{ cancellingCard ? "Cancelling…" : "Cancel Reading" }}
|
|
476
909
|
</AppButton>
|
|
477
910
|
</div>
|
|
478
911
|
</v-card-text>
|
|
479
|
-
<v-card-actions>
|
|
480
|
-
|
|
481
|
-
|
|
912
|
+
<v-card-actions class="screen-modal__footer">
|
|
913
|
+
<!-- Closing mid-read would leave the reader listening with nothing
|
|
914
|
+
to receive the card it reads. -->
|
|
915
|
+
<AppButton
|
|
916
|
+
variant="ghost"
|
|
917
|
+
:disabled="enrollingCard"
|
|
918
|
+
@click="cardDialog = false"
|
|
919
|
+
>
|
|
482
920
|
Close
|
|
483
921
|
</AppButton>
|
|
484
922
|
</v-card-actions>
|
|
@@ -486,31 +924,31 @@
|
|
|
486
924
|
</v-dialog>
|
|
487
925
|
|
|
488
926
|
<v-dialog v-model="deleteDialog" max-width="360" persistent>
|
|
489
|
-
<v-card
|
|
490
|
-
<v-card-title
|
|
491
|
-
<v-card-text class="text-body-2">
|
|
927
|
+
<v-card class="screen-modal">
|
|
928
|
+
<v-card-title>Delete</v-card-title>
|
|
929
|
+
<v-card-text class="screen-modal__body text-body-2">
|
|
492
930
|
Are you sure you want to permanently delete this HID user?
|
|
493
931
|
<div class="text-caption text-medium-emphasis mt-2">
|
|
494
932
|
This action will remove the user from the HID reader and mark the
|
|
495
933
|
identity as deleted.
|
|
496
934
|
</div>
|
|
497
935
|
</v-card-text>
|
|
498
|
-
<v-card-actions class="
|
|
936
|
+
<v-card-actions class="screen-modal__footer">
|
|
499
937
|
<v-btn
|
|
500
|
-
class="text-none
|
|
938
|
+
class="text-none screen-btn-ghost"
|
|
501
939
|
variant="flat"
|
|
502
|
-
height="44"
|
|
503
940
|
@click="deleteDialog = false"
|
|
504
|
-
>Cancel</v-btn
|
|
505
941
|
>
|
|
942
|
+
Cancel
|
|
943
|
+
</v-btn>
|
|
506
944
|
<v-btn
|
|
507
|
-
class="text-none
|
|
945
|
+
class="text-none screen-btn-primary"
|
|
508
946
|
variant="flat"
|
|
509
|
-
height="44"
|
|
510
947
|
:loading="deleting"
|
|
511
948
|
@click="deleteUser"
|
|
512
|
-
>Delete</v-btn
|
|
513
949
|
>
|
|
950
|
+
Delete
|
|
951
|
+
</v-btn>
|
|
514
952
|
</v-card-actions>
|
|
515
953
|
</v-card>
|
|
516
954
|
</v-dialog>
|
|
@@ -522,11 +960,55 @@
|
|
|
522
960
|
</template>
|
|
523
961
|
|
|
524
962
|
<script setup lang="ts">
|
|
963
|
+
// Shared with the Access Permissions screen: `paginate` never echoes the page it
|
|
964
|
+
// was asked for, and `items` sits at the root on some of these endpoints and
|
|
965
|
+
// under `data` on others. This reads both, so the mistake is made once.
|
|
966
|
+
import {
|
|
967
|
+
HID_ENROLMENT_SUBJECTS,
|
|
968
|
+
hiddenSubjectNote,
|
|
969
|
+
memberCandidateOf,
|
|
970
|
+
memberHasAccount,
|
|
971
|
+
HID_ALL_SERVICES,
|
|
972
|
+
permissionCategoryOf,
|
|
973
|
+
providerAccountsKnown,
|
|
974
|
+
providerCandidateOf,
|
|
975
|
+
providerEnrollableRows,
|
|
976
|
+
readMemberPage,
|
|
977
|
+
serviceFilterOf,
|
|
978
|
+
subjectCategoryLabel as labelForSubjectCategory,
|
|
979
|
+
subjectLinkOf,
|
|
980
|
+
type HidEnrolmentSubject,
|
|
981
|
+
} from "../utils/hid-enrolment-subject";
|
|
982
|
+
/* The SAME filter the Members > Service Providers screen applies, not a second
|
|
983
|
+
copy of the rule: a picker that disagrees with the screen the operator was
|
|
984
|
+
just looking at is a picker they cannot trust. Core applies it to the Excel
|
|
985
|
+
export too (`filterSiteProviderMembers`). */
|
|
986
|
+
import {
|
|
987
|
+
filterProviderMembers,
|
|
988
|
+
serviceTypeOptions,
|
|
989
|
+
} from "../utils/site-provider-members";
|
|
990
|
+
/* Bulk access is granted through the permissions document, and the PUT REPLACES
|
|
991
|
+
this reader's whole assignment set. These three are what keep a bulk add from
|
|
992
|
+
revoking everybody who was already on the door - see the file's own header. */
|
|
993
|
+
import {
|
|
994
|
+
seedAssignments,
|
|
995
|
+
setAssignment,
|
|
996
|
+
toAssignmentPayload,
|
|
997
|
+
type HidAssignmentState,
|
|
998
|
+
} from "../utils/hid-permission-assignments";
|
|
999
|
+
|
|
525
1000
|
const props = defineProps({
|
|
526
1001
|
site: {
|
|
527
1002
|
type: String,
|
|
528
1003
|
required: true,
|
|
529
1004
|
},
|
|
1005
|
+
// Which organisation the access this enrolment grants belongs to. Saving now
|
|
1006
|
+
// assigns the person to the reader, and the assignment is stored per
|
|
1007
|
+
// organisation - see `schemaCreateHidAmicoIdentity`.
|
|
1008
|
+
org: {
|
|
1009
|
+
type: String,
|
|
1010
|
+
default: "",
|
|
1011
|
+
},
|
|
530
1012
|
cardManagement: {
|
|
531
1013
|
type: Boolean,
|
|
532
1014
|
default: false,
|
|
@@ -547,12 +1029,21 @@ const {
|
|
|
547
1029
|
getUserPinStatus,
|
|
548
1030
|
setUserPin,
|
|
549
1031
|
deleteUserPin,
|
|
1032
|
+
getUserPasswordStatus,
|
|
1033
|
+
setUserPassword,
|
|
1034
|
+
deleteUserPassword,
|
|
550
1035
|
runObjectOperation,
|
|
551
1036
|
createIdentity,
|
|
552
1037
|
updateIdentity,
|
|
553
1038
|
deleteIdentity,
|
|
554
|
-
|
|
1039
|
+
getSitePermissions,
|
|
1040
|
+
updateSitePermissions,
|
|
555
1041
|
} = useHidAmico();
|
|
1042
|
+
const { getAll: getAllMembers } = useMember();
|
|
1043
|
+
/* Members > Service Providers reads this same call, so the enrolment picker
|
|
1044
|
+
offers exactly the people that screen lists. Site-scoped by the server. */
|
|
1045
|
+
const { getSiteMembers } = useServiceProvider();
|
|
1046
|
+
const runtimeConfig = useRuntimeConfig();
|
|
556
1047
|
|
|
557
1048
|
type HidMetadata = Record<string, unknown> & {
|
|
558
1049
|
name?: string;
|
|
@@ -566,6 +1057,7 @@ type HidMetadata = Record<string, unknown> & {
|
|
|
566
1057
|
lastAccessAt?: string;
|
|
567
1058
|
facialScores?: Record<string, unknown>;
|
|
568
1059
|
pinEnrolled?: boolean;
|
|
1060
|
+
passwordSet?: boolean;
|
|
569
1061
|
hidPhysicalCards?: HidCard[];
|
|
570
1062
|
};
|
|
571
1063
|
|
|
@@ -616,6 +1108,7 @@ type HidIdentityPayload = Record<string, unknown> & {
|
|
|
616
1108
|
const readers = ref<HidReader[]>([]);
|
|
617
1109
|
const users = ref<HidUser[]>([]);
|
|
618
1110
|
const administratorUserIds = ref<Set<number>>(new Set());
|
|
1111
|
+
const { selectHidReader, resolveForSite } = useHidReaderSelection();
|
|
619
1112
|
const selectedReaderId = ref("");
|
|
620
1113
|
const search = ref("");
|
|
621
1114
|
const status = ref("");
|
|
@@ -639,7 +1132,14 @@ const physicalCardErrorsByUserId = ref<Record<string, string>>({});
|
|
|
639
1132
|
const loadingCards = ref(false);
|
|
640
1133
|
const savingCard = ref(false);
|
|
641
1134
|
const enrollingCard = ref(false);
|
|
1135
|
+
/*
|
|
1136
|
+
* Cancelling makes the in-flight enrol REJECT, and that rejection is expected
|
|
1137
|
+
* rather than a failure. Without this flag the cancel raises an error toast on
|
|
1138
|
+
* top of the one confirming it, which reads as the cancel having gone wrong.
|
|
1139
|
+
*/
|
|
642
1140
|
const cardEnrollmentCancelled = ref(false);
|
|
1141
|
+
/** The cancel's own round trip to the reader — a second or two, not the enrol's. */
|
|
1142
|
+
const cancellingCard = ref(false);
|
|
643
1143
|
const deletingCardId = ref("");
|
|
644
1144
|
const showPin = ref(false);
|
|
645
1145
|
const pinEnrolled = ref(false);
|
|
@@ -648,54 +1148,889 @@ const cameraDialog = ref(false);
|
|
|
648
1148
|
const selectedPhotoFile = ref<File | null>(null);
|
|
649
1149
|
const removePhotoRequested = ref(false);
|
|
650
1150
|
/**
|
|
651
|
-
* Client requirement (2026-08-26): the photo is neither shown nor kept once registration
|
|
652
|
-
* completes, so the form tracks only WHETHER this person is enrolled. Re-enrolling needs a
|
|
653
|
-
* fresh capture -- there is no stored image to re-send.
|
|
1151
|
+
* Client requirement (2026-08-26): the photo is neither shown nor kept once registration
|
|
1152
|
+
* completes, so the form tracks only WHETHER this person is enrolled. Re-enrolling needs a
|
|
1153
|
+
* fresh capture -- there is no stored image to re-send.
|
|
1154
|
+
*/
|
|
1155
|
+
const facialEnrolled = ref(false);
|
|
1156
|
+
|
|
1157
|
+
function hasFacialEnrollment(user?: HidUser | null) {
|
|
1158
|
+
return Boolean(
|
|
1159
|
+
user?.metadata?.facialEnrolled ||
|
|
1160
|
+
user?.metadata?.facialData ||
|
|
1161
|
+
user?.metadata?.imageTimestamp
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* The reader's own reference for the enrolled face, shown while editing.
|
|
1167
|
+
*
|
|
1168
|
+
* We hold neither the image nor the biometric template - the reader does - so
|
|
1169
|
+
* this value is the only handle on the enrollment there is. `getFacialData` is
|
|
1170
|
+
* the roster's Facial Data column, reused here so the dialog and the table
|
|
1171
|
+
* cannot disagree; it answers "N/A" when there is nothing, which is the case
|
|
1172
|
+
* this must not draw a row for.
|
|
1173
|
+
*/
|
|
1174
|
+
const editFacialData = computed(() => {
|
|
1175
|
+
if (!selectedUser.value) return "";
|
|
1176
|
+
const value = String(getFacialData(selectedUser.value) ?? "").trim();
|
|
1177
|
+
return value && value !== "N/A" ? value : "";
|
|
1178
|
+
});
|
|
1179
|
+
const removePinRequested = ref(false);
|
|
1180
|
+
const passwordSet = ref(false);
|
|
1181
|
+
const showPassword = ref(false);
|
|
1182
|
+
const removePasswordRequested = ref(false);
|
|
1183
|
+
const snackbar = reactive({
|
|
1184
|
+
show: false,
|
|
1185
|
+
color: "success",
|
|
1186
|
+
message: "",
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
type OccupiedUnit = { _id: string; name: string; people: number };
|
|
1190
|
+
type OccupiedLevel = { _id: string; name: string; units?: OccupiedUnit[] };
|
|
1191
|
+
type OccupiedBlock = {
|
|
1192
|
+
_id: string;
|
|
1193
|
+
name: string;
|
|
1194
|
+
block: number | null;
|
|
1195
|
+
levels?: OccupiedLevel[];
|
|
1196
|
+
};
|
|
1197
|
+
type UnitPerson = {
|
|
1198
|
+
_id: string;
|
|
1199
|
+
name?: string;
|
|
1200
|
+
unitName?: string;
|
|
1201
|
+
/**
|
|
1202
|
+
* The resident's app account, and the reason this field is read here.
|
|
1203
|
+
*
|
|
1204
|
+
* A HID identity is refused unless its subject is a valid permission subject
|
|
1205
|
+
* at the site, and for a resident that means a `site.people` row WITH a
|
|
1206
|
+
* linked user - `assertPermissionSubject` in `iservice365-core` filters on
|
|
1207
|
+
* exactly this field. The permission model has no way to represent anyone
|
|
1208
|
+
* else: `resolvePermissionUserBindings` binds reader access by user id and
|
|
1209
|
+
* drops every row without one, so a resident enrolled without an account
|
|
1210
|
+
* would end up on the reader holding no access rules at all.
|
|
1211
|
+
*
|
|
1212
|
+
* The picker this replaced was fed by that same server-side function, so it
|
|
1213
|
+
* could never offer an unenrollable resident. The unit cascade asks People
|
|
1214
|
+
* Management instead, which has no such filter - hence this one.
|
|
1215
|
+
*/
|
|
1216
|
+
user?: string | null;
|
|
1217
|
+
};
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* THE SUBJECT TYPE THIS SCREEN WAS LAST USED FOR.
|
|
1221
|
+
*
|
|
1222
|
+
* Module scope, not component scope, and deliberately: the dialog is rebuilt on
|
|
1223
|
+
* every open and the component itself is unmounted when the operator moves
|
|
1224
|
+
* between HID screens, so a local ref would forget the choice immediately.
|
|
1225
|
+
* Enrolling ten guards in a row should not mean picking "Member" ten times.
|
|
1226
|
+
*
|
|
1227
|
+
* Not persisted. Surviving a hard refresh is a smaller benefit than surviving
|
|
1228
|
+
* the dialog, and `useHidReaderSelection` already set the precedent for where
|
|
1229
|
+
* that line sits.
|
|
1230
|
+
*/
|
|
1231
|
+
/* The dropdown's values, plus `service_provider` - which the dropdown does not
|
|
1232
|
+
offer but `openEdit` resolves from records that name a COMPANY. */
|
|
1233
|
+
type TEnrolmentSubjectValue = HidEnrolmentSubject | "service_provider";
|
|
1234
|
+
|
|
1235
|
+
const REMEMBERED_SUBJECT_CATEGORY = ref<HidEnrolmentSubject>("resident");
|
|
1236
|
+
|
|
1237
|
+
const form = reactive({
|
|
1238
|
+
reader: "",
|
|
1239
|
+
block: "",
|
|
1240
|
+
level: "",
|
|
1241
|
+
unit: "",
|
|
1242
|
+
name: "",
|
|
1243
|
+
hidUserId: "",
|
|
1244
|
+
registration: "",
|
|
1245
|
+
isAdministrator: false,
|
|
1246
|
+
accessPin: "",
|
|
1247
|
+
password: "",
|
|
1248
|
+
photoPreview: "",
|
|
1249
|
+
subjectCategory: REMEMBERED_SUBJECT_CATEGORY.value as TEnrolmentSubjectValue,
|
|
1250
|
+
subjectId: "",
|
|
1251
|
+
/** A mirror of the chosen member's role. Never sent; display only. */
|
|
1252
|
+
subjectRole: "",
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
const route = useRoute();
|
|
1256
|
+
const orgId = computed(() => String(route.params.org || ""));
|
|
1257
|
+
|
|
1258
|
+
// The cascade runs on People Management's own endpoints, the same pair the
|
|
1259
|
+
// resident forms use:
|
|
1260
|
+
// GET /api/people/site/:site/occupied-structure - blocks/levels/units that
|
|
1261
|
+
// actually have an occupant, so the picker cannot offer an empty unit
|
|
1262
|
+
// GET /api/people/unit/:unit - who is in the chosen one
|
|
1263
|
+
// Both are fetched on FIRST USE, never when the dialog opens.
|
|
1264
|
+
const { getOccupiedStructure, getPeopleByUnit } = usePeople();
|
|
1265
|
+
|
|
1266
|
+
// `resident,tenant` matches the occupancy endpoint's own default, so the units
|
|
1267
|
+
// it offers and the people this returns are the same set. Asking the two
|
|
1268
|
+
// halves different questions is how a unit ends up offered but empty.
|
|
1269
|
+
const OCCUPANT_TYPES = "resident,tenant";
|
|
1270
|
+
|
|
1271
|
+
const occupiedBlocks = ref<OccupiedBlock[]>([]);
|
|
1272
|
+
const occupancyLoaded = ref(false);
|
|
1273
|
+
const loadingOccupancy = ref(false);
|
|
1274
|
+
const unitPeople = ref<UnitPerson[]>([]);
|
|
1275
|
+
const loadingUnitPeople = ref(false);
|
|
1276
|
+
|
|
1277
|
+
// "Is this resident already enrolled on THIS reader?" - the reader keeps its own
|
|
1278
|
+
// user table, so the same person at a different gate is normal and does not
|
|
1279
|
+
// count. Held here rather than derived, because it is the answer to a request.
|
|
1280
|
+
//
|
|
1281
|
+
// The server refuses the duplicate as well (`createIdentity` in
|
|
1282
|
+
// `iservice365-core`), but by the time it answers, `saveUser` has already
|
|
1283
|
+
// created a user on the physical device and has to roll it back. Asking here
|
|
1284
|
+
// keeps that off the reader entirely.
|
|
1285
|
+
const residentConflict = ref<HidUser | null>(null);
|
|
1286
|
+
const checkingResident = ref(false);
|
|
1287
|
+
|
|
1288
|
+
// One spelling of a block, used by the picker and by the label stamped onto the
|
|
1289
|
+
// record. Two copies of this expression is how the dropdown and the Unit column
|
|
1290
|
+
// drift apart.
|
|
1291
|
+
function formatBlockTitle(block?: OccupiedBlock | null) {
|
|
1292
|
+
if (!block) return "";
|
|
1293
|
+
return block.name
|
|
1294
|
+
? `Block ${block.block} (${block.name})`
|
|
1295
|
+
: `Block ${block.block ?? ""}`.trim();
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
const blockOptions = computed(() =>
|
|
1299
|
+
occupiedBlocks.value.map((block) => ({
|
|
1300
|
+
title: formatBlockTitle(block),
|
|
1301
|
+
value: String(block._id ?? ""),
|
|
1302
|
+
}))
|
|
1303
|
+
);
|
|
1304
|
+
|
|
1305
|
+
const levelOptions = computed(() => {
|
|
1306
|
+
const block = occupiedBlocks.value.find(
|
|
1307
|
+
(item) => String(item._id ?? "") === form.block
|
|
1308
|
+
);
|
|
1309
|
+
return (block?.levels ?? []).map((level) => ({
|
|
1310
|
+
title: String(level.name ?? ""),
|
|
1311
|
+
value: String(level._id ?? ""),
|
|
1312
|
+
}));
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
const unitOptions = computed(() => {
|
|
1316
|
+
const block = occupiedBlocks.value.find(
|
|
1317
|
+
(item) => String(item._id ?? "") === form.block
|
|
1318
|
+
);
|
|
1319
|
+
const level = (block?.levels ?? []).find(
|
|
1320
|
+
(item) => String(item._id ?? "") === form.level
|
|
1321
|
+
);
|
|
1322
|
+
return (level?.units ?? []).map((unit) => ({
|
|
1323
|
+
title: String(unit.name ?? ""),
|
|
1324
|
+
value: String(unit._id ?? ""),
|
|
1325
|
+
}));
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1328
|
+
// Only the residents the server will actually accept - see `UnitPerson.user`.
|
|
1329
|
+
// Offering the rest produced "The linked HID identity subject is not active at
|
|
1330
|
+
// this site." at the very end of enrollment, after a user had already been
|
|
1331
|
+
// written to the reader.
|
|
1332
|
+
const enrollableUnitPeople = computed(() =>
|
|
1333
|
+
unitPeople.value.filter((person) => Boolean(person.user))
|
|
1334
|
+
);
|
|
1335
|
+
|
|
1336
|
+
const unenrollableUnitPeopleCount = computed(
|
|
1337
|
+
() => unitPeople.value.length - enrollableUnitPeople.value.length
|
|
1338
|
+
);
|
|
1339
|
+
|
|
1340
|
+
// `_id` here is `site.people._id`, which is exactly the `person` that
|
|
1341
|
+
// `createIdentity` maps a HID user to.
|
|
1342
|
+
const subjectOptions = computed(() =>
|
|
1343
|
+
enrollableUnitPeople.value.map((person) => ({
|
|
1344
|
+
title: String(person.name ?? "Resident"),
|
|
1345
|
+
value: String(person._id ?? ""),
|
|
1346
|
+
}))
|
|
1347
|
+
);
|
|
1348
|
+
|
|
1349
|
+
// Says why somebody the operator can see in People Management is missing here.
|
|
1350
|
+
// Without it the unit just looks empty and the screen looks broken.
|
|
1351
|
+
const unitResidentNote = computed(() => {
|
|
1352
|
+
if (
|
|
1353
|
+
!form.unit ||
|
|
1354
|
+
loadingUnitPeople.value ||
|
|
1355
|
+
!unenrollableUnitPeopleCount.value
|
|
1356
|
+
)
|
|
1357
|
+
return "";
|
|
1358
|
+
const count = unenrollableUnitPeopleCount.value;
|
|
1359
|
+
const subject = count === 1 ? "1 resident" : `${count} residents`;
|
|
1360
|
+
const verb = count === 1 ? "is" : "are";
|
|
1361
|
+
return `${subject} in this unit ${verb} not listed: HID enrollment needs a resident app account, and ${
|
|
1362
|
+
count === 1 ? "that person has" : "they have"
|
|
1363
|
+
} none yet.`;
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
/* ── THE SUBJECT TYPE ──────────────────────────────────────────────────── */
|
|
1367
|
+
|
|
1368
|
+
const subjectCategoryOptions = HID_ENROLMENT_SUBJECTS;
|
|
1369
|
+
|
|
1370
|
+
const isResidentSubject = computed(() => form.subjectCategory === "resident");
|
|
1371
|
+
/* Members and provider staff both pick ONE PERSON from a flat list, but from
|
|
1372
|
+
different lists, so they are separate branches rather than "not a resident". */
|
|
1373
|
+
const isMemberSubject = computed(
|
|
1374
|
+
() => form.subjectCategory === "property_management"
|
|
1375
|
+
);
|
|
1376
|
+
const isProviderSubject = computed(
|
|
1377
|
+
() => form.subjectCategory === "service_provider_staff"
|
|
1378
|
+
);
|
|
1379
|
+
/** Either of the two person pickers - what the Role mirror follows. */
|
|
1380
|
+
const isPersonSubject = computed(
|
|
1381
|
+
() => isMemberSubject.value || isProviderSubject.value
|
|
1382
|
+
);
|
|
1383
|
+
|
|
1384
|
+
/* The mirror's label follows the list it mirrors. A member's subtitle is their
|
|
1385
|
+
role at the site; a contractor's is the firm they work for, and calling that
|
|
1386
|
+
"Role" would read as a mistake. */
|
|
1387
|
+
const subjectMirrorLabel = computed(() =>
|
|
1388
|
+
isProviderSubject.value ? "Company" : "Role"
|
|
1389
|
+
);
|
|
1390
|
+
const subjectMirrorPlaceholder = computed(() =>
|
|
1391
|
+
isProviderSubject.value ? "Select a person above" : "Select a member above"
|
|
1392
|
+
);
|
|
1393
|
+
|
|
1394
|
+
/* What an EDIT shows in place of the control, since the type cannot change.
|
|
1395
|
+
Looked up in the util, not in the switch's own options: records carry
|
|
1396
|
+
`service_provider` too, and the options list would have called those
|
|
1397
|
+
"Resident". */
|
|
1398
|
+
const subjectCategoryLabel = computed(() =>
|
|
1399
|
+
labelForSubjectCategory(form.subjectCategory)
|
|
1400
|
+
);
|
|
1401
|
+
|
|
1402
|
+
/* ── STAFF CANDIDATES ─────────────────────────────────────────────────────
|
|
1403
|
+
*
|
|
1404
|
+
* Read from `GET /api/members` — the SAME endpoint, with the same filters, that
|
|
1405
|
+
* the Members menu reads. That is deliberate and was the fix for a real bug: the
|
|
1406
|
+
* first version used the HID permission-candidate endpoint, which applies NO
|
|
1407
|
+
* `type` filter, so it offered every active members row at the site including
|
|
1408
|
+
* service providers and residents. A picker that disagrees with the Members menu
|
|
1409
|
+
* is a picker the operator cannot trust.
|
|
1410
|
+
*
|
|
1411
|
+
* `type` is the running app, the same way `HidQrCodeConfiguration` reads it, so
|
|
1412
|
+
* the property app lists its own staff and the security app lists its own. The
|
|
1413
|
+
* members endpoint expands legacy spellings of a type itself (`appTypeQuery`).
|
|
1414
|
+
*
|
|
1415
|
+
* Service providers get their own option next; they are not silently folded in
|
|
1416
|
+
* here.
|
|
1417
|
+
*/
|
|
1418
|
+
/*
|
|
1419
|
+
* TEN, to share the Members menu's cache key.
|
|
1420
|
+
*
|
|
1421
|
+
* `member.repo.ts` caches each page under a key that INCLUDES `limit`, so a
|
|
1422
|
+
* different limit is a different cache entry with its own 15-minute life. This
|
|
1423
|
+
* picker asked for 50 and got its own rarely-touched slot, which went stale and
|
|
1424
|
+
* showed six members where the Members menu showed nine - the same query, the
|
|
1425
|
+
* same instant, two answers. Asking for the menu's own page size means the two
|
|
1426
|
+
* read the same entry and cannot disagree.
|
|
1427
|
+
*/
|
|
1428
|
+
const MEMBER_PAGE_LIMIT = 10;
|
|
1429
|
+
|
|
1430
|
+
type MemberCandidate = { subjectId: string; name: string; subtitle: string };
|
|
1431
|
+
|
|
1432
|
+
const memberCandidates = ref<MemberCandidate[]>([]);
|
|
1433
|
+
const loadingMembers = ref(false);
|
|
1434
|
+
const loadingMoreMembers = ref(false);
|
|
1435
|
+
const membersLoaded = ref(false);
|
|
1436
|
+
/** The last page successfully loaded; 0 before the first. */
|
|
1437
|
+
const memberPage = ref(0);
|
|
1438
|
+
/** Rows fetched so far, INCLUDING those hidden for having no account. */
|
|
1439
|
+
const memberRowsLoaded = ref(0);
|
|
1440
|
+
/** What the server says matches the query, across every page. */
|
|
1441
|
+
const memberTotalRows = ref(0);
|
|
1442
|
+
/* Active staff dropped for having no app account. Counted here rather than
|
|
1443
|
+
* asked of the server, because this endpoint returns the `user` field and so
|
|
1444
|
+
* the answer is already in hand - see `hiddenMemberNote`. */
|
|
1445
|
+
const hiddenMemberCount = ref(0);
|
|
1446
|
+
|
|
1447
|
+
const hasMoreMembers = computed(
|
|
1448
|
+
() => memberRowsLoaded.value < memberTotalRows.value
|
|
1449
|
+
);
|
|
1450
|
+
|
|
1451
|
+
/*
|
|
1452
|
+
* Bumped on every request, so a response that is no longer the current one is
|
|
1453
|
+
* dropped. Scroll-loading makes this matter: a page still in flight when the
|
|
1454
|
+
* dialog reopens, or when the type is switched away and back, would otherwise
|
|
1455
|
+
* append onto a list that has since been replaced. Same guard, and the same
|
|
1456
|
+
* reason, as `cardRequestSeq` in `EntryPassInformation.vue`.
|
|
1457
|
+
*/
|
|
1458
|
+
let memberRequestSeq = 0;
|
|
1459
|
+
|
|
1460
|
+
const memberOptions = computed(() =>
|
|
1461
|
+
memberCandidates.value.map((candidate) => ({
|
|
1462
|
+
// Role in the title as well as the mirror below: two people can share a name,
|
|
1463
|
+
// and the popover is where they have to be told apart.
|
|
1464
|
+
title: candidate.subtitle
|
|
1465
|
+
? `${candidate.name} — ${candidate.subtitle}`
|
|
1466
|
+
: candidate.name,
|
|
1467
|
+
value: candidate.subjectId,
|
|
1468
|
+
}))
|
|
1469
|
+
);
|
|
1470
|
+
|
|
1471
|
+
const memberPlaceholder = computed(() => {
|
|
1472
|
+
if (loadingMembers.value) return "Loading staff...";
|
|
1473
|
+
if (!membersLoaded.value) return "Select a member";
|
|
1474
|
+
if (memberOptions.value.length) return "Select a member";
|
|
1475
|
+
return hiddenMemberCount.value
|
|
1476
|
+
? "No staff here can be enrolled yet"
|
|
1477
|
+
: "No staff at this site";
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
/* The same courtesy the resident cascade pays: say why somebody the operator
|
|
1481
|
+
* can see in the Members menu is not in this list. */
|
|
1482
|
+
const hiddenMemberNote = computed(() => {
|
|
1483
|
+
// Only once everything is loaded. Half a list gives half a count, and
|
|
1484
|
+
// "3 members at this site" would then be a number about this PAGE wearing the
|
|
1485
|
+
// word "site" - the range note below carries the partial truth instead.
|
|
1486
|
+
if (loadingMembers.value || hasMoreMembers.value) return "";
|
|
1487
|
+
return hiddenSubjectNote(hiddenMemberCount.value, "member");
|
|
1488
|
+
});
|
|
1489
|
+
|
|
1490
|
+
/* What is loaded against what exists - and the warning that the picker's own
|
|
1491
|
+
search only ever sees what has been fetched. */
|
|
1492
|
+
const memberRangeNote = computed(() =>
|
|
1493
|
+
hasMoreMembers.value
|
|
1494
|
+
? `Showing ${memberRowsLoaded.value} of ${memberTotalRows.value}. Search looks only at these.`
|
|
1495
|
+
: ""
|
|
1496
|
+
);
|
|
1497
|
+
|
|
1498
|
+
/** Back to "nothing loaded", so the next open starts from page one. */
|
|
1499
|
+
function resetMemberCandidates() {
|
|
1500
|
+
// Anything still in flight belongs to the list being thrown away.
|
|
1501
|
+
memberRequestSeq += 1;
|
|
1502
|
+
memberCandidates.value = [];
|
|
1503
|
+
hiddenMemberCount.value = 0;
|
|
1504
|
+
memberPage.value = 0;
|
|
1505
|
+
memberRowsLoaded.value = 0;
|
|
1506
|
+
memberTotalRows.value = 0;
|
|
1507
|
+
membersLoaded.value = false;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
/**
|
|
1511
|
+
* One page of this site's active staff.
|
|
1512
|
+
*
|
|
1513
|
+
* Paged rather than fetched whole: a page of ten is what the Members menu asks
|
|
1514
|
+
* for, and sharing that page size shares its cache entry. `append` is what
|
|
1515
|
+
* separates the first load from Load more - the first replaces, the rest add.
|
|
1516
|
+
*
|
|
1517
|
+
* NEVER THROWS. The list is left as it is and the placeholder says so, because
|
|
1518
|
+
* choosing "Member" must not break the dialog.
|
|
1519
|
+
*/
|
|
1520
|
+
async function fetchMemberPage(page: number, append: boolean) {
|
|
1521
|
+
const memberType = String(runtimeConfig.public.APP ?? "").trim();
|
|
1522
|
+
// "App" is the nuxt config's own placeholder default. Without a real app there
|
|
1523
|
+
// is no member type to ask for, and a typeless query returns nothing anyway.
|
|
1524
|
+
if (!props.site || !props.org || !memberType || memberType === "App") {
|
|
1525
|
+
resetMemberCandidates();
|
|
1526
|
+
membersLoaded.value = true;
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
const seq = (memberRequestSeq += 1);
|
|
1531
|
+
const busy = append ? loadingMoreMembers : loadingMembers;
|
|
1532
|
+
busy.value = true;
|
|
1533
|
+
try {
|
|
1534
|
+
const response = await getAllMembers({
|
|
1535
|
+
org: props.org,
|
|
1536
|
+
siteId: props.site,
|
|
1537
|
+
type: memberType,
|
|
1538
|
+
status: "active",
|
|
1539
|
+
page,
|
|
1540
|
+
limit: MEMBER_PAGE_LIMIT,
|
|
1541
|
+
});
|
|
1542
|
+
if (seq !== memberRequestSeq) return;
|
|
1543
|
+
const parsed = readMemberPage(response);
|
|
1544
|
+
const enrollable = parsed.items.filter(memberHasAccount);
|
|
1545
|
+
const candidates = enrollable.map(memberCandidateOf);
|
|
1546
|
+
|
|
1547
|
+
memberCandidates.value = append
|
|
1548
|
+
? [...memberCandidates.value, ...candidates]
|
|
1549
|
+
: candidates;
|
|
1550
|
+
const hiddenOnPage = parsed.items.length - enrollable.length;
|
|
1551
|
+
hiddenMemberCount.value = append
|
|
1552
|
+
? hiddenMemberCount.value + hiddenOnPage
|
|
1553
|
+
: hiddenOnPage;
|
|
1554
|
+
memberRowsLoaded.value = append
|
|
1555
|
+
? memberRowsLoaded.value + parsed.items.length
|
|
1556
|
+
: parsed.items.length;
|
|
1557
|
+
memberTotalRows.value = parsed.total;
|
|
1558
|
+
memberPage.value = page;
|
|
1559
|
+
membersLoaded.value = true;
|
|
1560
|
+
} catch (error: unknown) {
|
|
1561
|
+
if (seq !== memberRequestSeq) return;
|
|
1562
|
+
console.error("Unable to load staff for HID enrollment:", error);
|
|
1563
|
+
// An "append" that failed keeps what is already on screen; a first load has
|
|
1564
|
+
// nothing to keep. Either way the dialog stays usable.
|
|
1565
|
+
if (!append) resetMemberCandidates();
|
|
1566
|
+
membersLoaded.value = true;
|
|
1567
|
+
} finally {
|
|
1568
|
+
// Always its OWN flag, superseded or not: a request that leaves its spinner
|
|
1569
|
+
// set strands the list behind a wait that will never end.
|
|
1570
|
+
busy.value = false;
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
/** The first page, replacing whatever was there. */
|
|
1575
|
+
function loadMemberCandidates() {
|
|
1576
|
+
return fetchMemberPage(1, false);
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/** The next page, added to the list. */
|
|
1580
|
+
function loadMoreMembers() {
|
|
1581
|
+
if (loadingMembers.value || loadingMoreMembers.value || !hasMoreMembers.value)
|
|
1582
|
+
return;
|
|
1583
|
+
return fetchMemberPage(memberPage.value + 1, true);
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/* ── SERVICE PROVIDER STAFF ───────────────────────────────────────────────
|
|
1587
|
+
*
|
|
1588
|
+
* ONE PERSON from a provider company, never the company.
|
|
1589
|
+
*
|
|
1590
|
+
* Read from `GET /api/service-providers/site-members` - the same call behind
|
|
1591
|
+
* Members > Service Providers, with the same `filterProviderMembers` rule - so
|
|
1592
|
+
* the picker lists exactly the people that screen lists. The server scopes it to
|
|
1593
|
+
* the site; these are the contractors who attend THIS site, not every employee
|
|
1594
|
+
* of every firm engaged with the organisation.
|
|
1595
|
+
*
|
|
1596
|
+
* Unpaged, deliberately, unlike the staff picker above: this endpoint returns
|
|
1597
|
+
* the site's people in one response (capped at 1000 by the repository) and has
|
|
1598
|
+
* no per-page cache entry to disagree with. The service filter and the search
|
|
1599
|
+
* box both run over the loaded list, so neither costs a request.
|
|
1600
|
+
*/
|
|
1601
|
+
const providerPeople = ref<Record<string, unknown>[]>([]);
|
|
1602
|
+
const providerCompanyCount = ref(0);
|
|
1603
|
+
const loadingProviders = ref(false);
|
|
1604
|
+
const providersLoaded = ref(false);
|
|
1605
|
+
/**
|
|
1606
|
+
* All services is the default, the same as the Members screen - but it carries a
|
|
1607
|
+
* REAL value here, not "". `AppSelect`'s `hasSingleValue` is false for an empty
|
|
1608
|
+
* string, so an empty-valued option draws the placeholder in placeholder grey and
|
|
1609
|
+
* is never marked as chosen: the filter worked and the field looked blank. See
|
|
1610
|
+
* `serviceFilterOf`.
|
|
1611
|
+
*/
|
|
1612
|
+
const providerService = ref<string>(HID_ALL_SERVICES);
|
|
1613
|
+
|
|
1614
|
+
/** What the filter actually receives: "" for All services. */
|
|
1615
|
+
const providerServiceFilter = computed(() => serviceFilterOf(providerService.value));
|
|
1616
|
+
let providerRequestSeq = 0;
|
|
1617
|
+
|
|
1618
|
+
/** Everyone who can actually be given a door: an app account, and active. */
|
|
1619
|
+
const providerEnrollable = computed(() =>
|
|
1620
|
+
providerEnrollableRows(providerPeople.value)
|
|
1621
|
+
);
|
|
1622
|
+
|
|
1623
|
+
/* Whether the endpoint told us who holds an app account. It only began
|
|
1624
|
+
returning `user` in core 3.130, and this layer can be deployed ahead of it. */
|
|
1625
|
+
const providerAccountsVisible = computed(() =>
|
|
1626
|
+
providerAccountsKnown(providerPeople.value)
|
|
1627
|
+
);
|
|
1628
|
+
|
|
1629
|
+
/** People the operator can see on the Members screen but cannot enrol here. */
|
|
1630
|
+
const hiddenProviderCount = computed(
|
|
1631
|
+
() => providerPeople.value.length - providerEnrollable.value.length
|
|
1632
|
+
);
|
|
1633
|
+
|
|
1634
|
+
const providerServiceOptions = computed(() => [
|
|
1635
|
+
{ title: "All services", value: HID_ALL_SERVICES },
|
|
1636
|
+
// From the ENROLLABLE set, so choosing a service never lands on an empty list.
|
|
1637
|
+
...serviceTypeOptions(providerEnrollable.value as Array<{ type?: string; typeLabel?: string }>),
|
|
1638
|
+
]);
|
|
1639
|
+
|
|
1640
|
+
/** The filtered list - what both the picker and the bulk table draw. */
|
|
1641
|
+
const providerRows = computed(() =>
|
|
1642
|
+
filterProviderMembers(providerEnrollable.value as Array<Record<string, any>>, {
|
|
1643
|
+
type: providerServiceFilter.value,
|
|
1644
|
+
})
|
|
1645
|
+
);
|
|
1646
|
+
|
|
1647
|
+
const providerOptions = computed(() =>
|
|
1648
|
+
providerRows.value.map((row) => {
|
|
1649
|
+
const candidate = providerCandidateOf(row);
|
|
1650
|
+
return {
|
|
1651
|
+
// The firm in the title as well as the mirror: two contractors sharing a
|
|
1652
|
+
// first name is the normal case, and the popover is where they have to be
|
|
1653
|
+
// told apart.
|
|
1654
|
+
title: candidate.subtitle
|
|
1655
|
+
? `${candidate.name} — ${candidate.subtitle}`
|
|
1656
|
+
: candidate.name,
|
|
1657
|
+
value: candidate.subjectId,
|
|
1658
|
+
};
|
|
1659
|
+
})
|
|
1660
|
+
);
|
|
1661
|
+
|
|
1662
|
+
const providerPlaceholder = computed(() => {
|
|
1663
|
+
if (loadingProviders.value) return "Loading people...";
|
|
1664
|
+
if (!providersLoaded.value) return "Select a person";
|
|
1665
|
+
if (providerOptions.value.length) return "Select a person";
|
|
1666
|
+
if (providerServiceFilter.value) return "Nobody in this service can be enrolled yet";
|
|
1667
|
+
if (hiddenProviderCount.value) return "Nobody here can be enrolled yet";
|
|
1668
|
+
return providerCompanyCount.value
|
|
1669
|
+
? "No service provider people at this site"
|
|
1670
|
+
: "No service providers work at this site yet";
|
|
1671
|
+
});
|
|
1672
|
+
|
|
1673
|
+
/* Say why somebody on the Members screen is not in this list, rather than
|
|
1674
|
+
letting a missing colleague read as a broken dialog. */
|
|
1675
|
+
const hiddenProviderNote = computed(() => {
|
|
1676
|
+
if (loadingProviders.value || !hiddenProviderCount.value) return "";
|
|
1677
|
+
const count = hiddenProviderCount.value;
|
|
1678
|
+
const subject = count === 1 ? "1 person" : `${count} people`;
|
|
1679
|
+
const verb = count === 1 ? "is" : "are";
|
|
1680
|
+
const holder = count === 1 ? "that person is" : "they are";
|
|
1681
|
+
const needs = providerAccountsVisible.value
|
|
1682
|
+
? "an active membership with an app account"
|
|
1683
|
+
: "an active membership at this site";
|
|
1684
|
+
return `${subject} from this site's service providers ${verb} not listed:`
|
|
1685
|
+
+ ` HID enrollment needs ${needs}, and ${holder} without one.`;
|
|
1686
|
+
});
|
|
1687
|
+
|
|
1688
|
+
/** Back to "nothing loaded", so the next open starts clean. */
|
|
1689
|
+
function resetProviderCandidates() {
|
|
1690
|
+
providerRequestSeq += 1;
|
|
1691
|
+
providerPeople.value = [];
|
|
1692
|
+
providerCompanyCount.value = 0;
|
|
1693
|
+
providerService.value = HID_ALL_SERVICES;
|
|
1694
|
+
providersLoaded.value = false;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
/**
|
|
1698
|
+
* This site's service provider people.
|
|
1699
|
+
*
|
|
1700
|
+
* NEVER THROWS, on the same rule as the staff loader: choosing Service provider
|
|
1701
|
+
* must not break the dialog. The placeholder carries the failure.
|
|
1702
|
+
*/
|
|
1703
|
+
async function loadProviderCandidates() {
|
|
1704
|
+
if (!props.site) {
|
|
1705
|
+
resetProviderCandidates();
|
|
1706
|
+
providersLoaded.value = true;
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
const seq = (providerRequestSeq += 1);
|
|
1710
|
+
loadingProviders.value = true;
|
|
1711
|
+
try {
|
|
1712
|
+
const data = await getSiteMembers(props.site);
|
|
1713
|
+
// A response that arrived after the dialog was reopened belongs to a list
|
|
1714
|
+
// that no longer exists - the same guard the staff pager uses.
|
|
1715
|
+
if (seq !== providerRequestSeq) return;
|
|
1716
|
+
providerPeople.value = Array.isArray(data?.items)
|
|
1717
|
+
? (data.items as unknown as Record<string, unknown>[])
|
|
1718
|
+
: [];
|
|
1719
|
+
providerCompanyCount.value = Array.isArray(data?.companies)
|
|
1720
|
+
? data.companies.length
|
|
1721
|
+
: 0;
|
|
1722
|
+
providersLoaded.value = true;
|
|
1723
|
+
} catch (error: unknown) {
|
|
1724
|
+
if (seq !== providerRequestSeq) return;
|
|
1725
|
+
console.error("Unable to load service provider people for HID enrollment:", error);
|
|
1726
|
+
providerPeople.value = [];
|
|
1727
|
+
providerCompanyCount.value = 0;
|
|
1728
|
+
providersLoaded.value = true;
|
|
1729
|
+
} finally {
|
|
1730
|
+
loadingProviders.value = false;
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
/** The chosen person, restated into Name and the Company mirror. */
|
|
1735
|
+
function onProviderChanged() {
|
|
1736
|
+
// Compared on the TRIMMED id, because that is what the option's value carries
|
|
1737
|
+
// (`providerCandidateOf` trims) - matching the raw field would silently find
|
|
1738
|
+
// nobody and blank the name the operator just picked.
|
|
1739
|
+
const row = providerRows.value.find(
|
|
1740
|
+
(item) => String(item._id ?? "").trim() === form.subjectId
|
|
1741
|
+
);
|
|
1742
|
+
const candidate = row ? providerCandidateOf(row) : null;
|
|
1743
|
+
form.name = candidate?.name ?? "";
|
|
1744
|
+
form.subjectRole = candidate?.subtitle ?? "";
|
|
1745
|
+
void checkSubjectEnrollment();
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
/* Changing the service can take the chosen person out of the list. Leaving a
|
|
1749
|
+
selection that is no longer on screen is how a save ends up naming somebody
|
|
1750
|
+
the operator cannot see. */
|
|
1751
|
+
function onProviderServiceChanged() {
|
|
1752
|
+
if (!form.subjectId) return;
|
|
1753
|
+
const stillThere = providerRows.value.some(
|
|
1754
|
+
(item) => String(item._id ?? "").trim() === form.subjectId
|
|
1755
|
+
);
|
|
1756
|
+
if (stillThere) return;
|
|
1757
|
+
form.subjectId = "";
|
|
1758
|
+
form.name = "";
|
|
1759
|
+
form.subjectRole = "";
|
|
1760
|
+
residentConflict.value = null;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
/* ── ADD USERS IN BULK ────────────────────────────────────────────────────
|
|
1764
|
+
*
|
|
1765
|
+
* ACCESS, NOT CREDENTIALS. This grants a reader group to many people at once and
|
|
1766
|
+
* stops there - no face, no card, no PIN. Each person then adds their own
|
|
1767
|
+
* credential from their app, which attaches to the HID user this grant created
|
|
1768
|
+
* (`getProfileEnrollmentContext` reuses the existing profile identity), so
|
|
1769
|
+
* nobody has to be enrolled twice.
|
|
1770
|
+
*
|
|
1771
|
+
* It writes through `PUT /sites/:siteId/permissions`, the one call that writes
|
|
1772
|
+
* group membership, and the device users are created by the reconcile that call
|
|
1773
|
+
* triggers: `provisionPermissionUser` writes `{ name, registration }` and
|
|
1774
|
+
* nothing else. That is exactly "user record and group, credentials empty".
|
|
1775
|
+
*
|
|
1776
|
+
* THE TRAP, and the reason the saved set is fetched first: that PUT REPLACES
|
|
1777
|
+
* this reader's whole assignment set. Sending only the ticked contractors would
|
|
1778
|
+
* revoke every resident and every member on the door. The payload is always
|
|
1779
|
+
* built from the reader's existing assignments PLUS the selection.
|
|
1780
|
+
*/
|
|
1781
|
+
const bulkDialog = ref(false);
|
|
1782
|
+
const bulkLoading = ref(false);
|
|
1783
|
+
const bulkSaving = ref(false);
|
|
1784
|
+
const bulkSearch = ref("");
|
|
1785
|
+
/** The reader's CURRENT assignments, across every category. The merge base. */
|
|
1786
|
+
const bulkSaved = ref<HidAssignmentState>(new Map());
|
|
1787
|
+
/** Who is being added now. Already-granted people are never in here. */
|
|
1788
|
+
const bulkSelected = ref<Set<string>>(new Set());
|
|
1789
|
+
|
|
1790
|
+
/* NAME THE DOOR. A bulk grant is reader-scoped and the reader was chosen on the
|
|
1791
|
+
screen behind two dialogs, so the confirmation says which one rather than
|
|
1792
|
+
leaving the operator to remember. */
|
|
1793
|
+
const bulkReaderLabel = computed(() => {
|
|
1794
|
+
const reader = selectedReader.value;
|
|
1795
|
+
const name = String(reader?.name ?? "").trim() || "this HID reader";
|
|
1796
|
+
const location = String(reader?.location ?? "").trim();
|
|
1797
|
+
return location ? `${name} · ${location}` : name;
|
|
1798
|
+
});
|
|
1799
|
+
|
|
1800
|
+
/** The same people the picker offers, narrowed by this dialog's own search. */
|
|
1801
|
+
const bulkRows = computed(() => {
|
|
1802
|
+
const rows = filterProviderMembers(
|
|
1803
|
+
providerRows.value as Array<Record<string, any>>,
|
|
1804
|
+
{ search: bulkSearch.value }
|
|
1805
|
+
);
|
|
1806
|
+
return rows.map((row) => {
|
|
1807
|
+
const candidate = providerCandidateOf(row);
|
|
1808
|
+
const id = candidate.subjectId;
|
|
1809
|
+
return {
|
|
1810
|
+
subjectId: id,
|
|
1811
|
+
name: candidate.name,
|
|
1812
|
+
company: String(row.company ?? "").trim(),
|
|
1813
|
+
service: String(row.typeLabel ?? row.type ?? "").trim(),
|
|
1814
|
+
// Already on the door. Ticked and locked rather than hidden: hiding them
|
|
1815
|
+
// would make "select all" look like it had missed people, and unticking
|
|
1816
|
+
// one here would revoke access this dialog is not for revoking.
|
|
1817
|
+
granted: bulkSaved.value.has(id),
|
|
1818
|
+
};
|
|
1819
|
+
});
|
|
1820
|
+
});
|
|
1821
|
+
|
|
1822
|
+
const bulkSelectableRows = computed(() =>
|
|
1823
|
+
bulkRows.value.filter((row) => !row.granted)
|
|
1824
|
+
);
|
|
1825
|
+
const bulkSelectedCount = computed(() => bulkSelected.value.size);
|
|
1826
|
+
const bulkAllSelected = computed(
|
|
1827
|
+
() =>
|
|
1828
|
+
bulkSelectableRows.value.length > 0 &&
|
|
1829
|
+
bulkSelectableRows.value.every((row) => bulkSelected.value.has(row.subjectId))
|
|
1830
|
+
);
|
|
1831
|
+
const bulkGrantedCount = computed(
|
|
1832
|
+
() => bulkRows.value.filter((row) => row.granted).length
|
|
1833
|
+
);
|
|
1834
|
+
|
|
1835
|
+
const bulkEmptyText = computed(() => {
|
|
1836
|
+
if (bulkLoading.value) return "Loading...";
|
|
1837
|
+
if (bulkSearch.value.trim()) return "No one matches this search.";
|
|
1838
|
+
if (providerServiceFilter.value) return "No one in this service can be enrolled yet.";
|
|
1839
|
+
return "No service provider people can be enrolled at this site yet.";
|
|
1840
|
+
});
|
|
1841
|
+
|
|
1842
|
+
function isBulkSelected(subjectId: string) {
|
|
1843
|
+
return bulkSelected.value.has(subjectId);
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
function toggleBulkRow(subjectId: string, selected: boolean) {
|
|
1847
|
+
const next = new Set(bulkSelected.value);
|
|
1848
|
+
if (selected) next.add(subjectId);
|
|
1849
|
+
else next.delete(subjectId);
|
|
1850
|
+
bulkSelected.value = next;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
/* Select-all acts on the ROWS IN FRONT OF THE OPERATOR - what the service filter
|
|
1854
|
+
and the search have left - never on the whole estate. A tick box cannot
|
|
1855
|
+
quietly grant a door to people who are not on screen. */
|
|
1856
|
+
function toggleBulkAll(selected: boolean) {
|
|
1857
|
+
const next = new Set(bulkSelected.value);
|
|
1858
|
+
for (const row of bulkSelectableRows.value) {
|
|
1859
|
+
if (selected) next.add(row.subjectId);
|
|
1860
|
+
else next.delete(row.subjectId);
|
|
1861
|
+
}
|
|
1862
|
+
bulkSelected.value = next;
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
/**
|
|
1866
|
+
* Open the bulk dialog, after reading what the reader already holds.
|
|
1867
|
+
*
|
|
1868
|
+
* The saved set is fetched EVERY time rather than cached: it is the merge base
|
|
1869
|
+
* for a write that replaces the reader's assignments, and a stale one would
|
|
1870
|
+
* revoke whoever was granted in between.
|
|
1871
|
+
*/
|
|
1872
|
+
async function openBulkAdd() {
|
|
1873
|
+
const readerId =
|
|
1874
|
+
selectedReaderId.value || form.reader || readers.value[0]?._id;
|
|
1875
|
+
if (!readerId) {
|
|
1876
|
+
showToast("Select a HID reader before adding people in bulk.", "error");
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
/* The permissions endpoints REQUIRE an org - `schemaHidPermissionScopeQuery`
|
|
1880
|
+
has it as `required()`, unlike `createIdentity`, which falls back to the
|
|
1881
|
+
site's own. Both pages that mount this component pass one, so this is a
|
|
1882
|
+
guard against a 400 that would read as a server fault. */
|
|
1883
|
+
if (!props.org) {
|
|
1884
|
+
showToast(
|
|
1885
|
+
"This screen cannot grant access in bulk without an organization.",
|
|
1886
|
+
"error"
|
|
1887
|
+
);
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
bulkSearch.value = "";
|
|
1891
|
+
bulkSelected.value = new Set();
|
|
1892
|
+
bulkSaved.value = new Map();
|
|
1893
|
+
bulkDialog.value = true;
|
|
1894
|
+
bulkLoading.value = true;
|
|
1895
|
+
try {
|
|
1896
|
+
if (!providersLoaded.value) await loadProviderCandidates();
|
|
1897
|
+
const response = await getSitePermissions(props.site, props.org, readerId);
|
|
1898
|
+
bulkSaved.value = seedAssignments(response?.data?.assignments ?? []);
|
|
1899
|
+
} catch (error: unknown) {
|
|
1900
|
+
// Closed rather than left open on an empty merge base: saving from here
|
|
1901
|
+
// would send only the ticked people and revoke everybody else.
|
|
1902
|
+
bulkDialog.value = false;
|
|
1903
|
+
showToast(
|
|
1904
|
+
apiReason(error) || "Unable to read this reader's current access.",
|
|
1905
|
+
"error"
|
|
1906
|
+
);
|
|
1907
|
+
} finally {
|
|
1908
|
+
bulkLoading.value = false;
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
/** Grant the ticked people, keeping everyone the reader already admits. */
|
|
1913
|
+
async function saveBulkAdd() {
|
|
1914
|
+
if (bulkSaving.value || !bulkSelected.value.size) return;
|
|
1915
|
+
const readerId =
|
|
1916
|
+
selectedReaderId.value || form.reader || readers.value[0]?._id;
|
|
1917
|
+
if (!readerId) {
|
|
1918
|
+
showToast("Select a HID reader before adding people in bulk.", "error");
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
bulkSaving.value = true;
|
|
1923
|
+
try {
|
|
1924
|
+
// Seeded from what the server holds, so every category the reader already
|
|
1925
|
+
// admits survives this write.
|
|
1926
|
+
let draft: HidAssignmentState = new Map(bulkSaved.value);
|
|
1927
|
+
for (const subjectId of bulkSelected.value) {
|
|
1928
|
+
draft = setAssignment(
|
|
1929
|
+
draft,
|
|
1930
|
+
bulkSaved.value,
|
|
1931
|
+
subjectId,
|
|
1932
|
+
// A provider employee's grant is stored under the STAFF category: core's
|
|
1933
|
+
// `permissionSubjectOfIdentity` reads the `member` link and cannot see
|
|
1934
|
+
// this dropdown, and that category's subject list already includes these
|
|
1935
|
+
// people. See `permissionCategoryOf`.
|
|
1936
|
+
permissionCategoryOf("service_provider_staff") as THidPermissionCategory,
|
|
1937
|
+
true
|
|
1938
|
+
);
|
|
1939
|
+
}
|
|
1940
|
+
const added = bulkSelected.value.size;
|
|
1941
|
+
await updateSitePermissions(
|
|
1942
|
+
props.site,
|
|
1943
|
+
props.org,
|
|
1944
|
+
readerId,
|
|
1945
|
+
toAssignmentPayload(draft)
|
|
1946
|
+
);
|
|
1947
|
+
// Both dialogs close and the list is re-read: the reconcile has just created
|
|
1948
|
+
// a HID user for each person, so the roster behind this dialog is stale.
|
|
1949
|
+
bulkDialog.value = false;
|
|
1950
|
+
formDialog.value = false;
|
|
1951
|
+
await loadUsers();
|
|
1952
|
+
showToast(
|
|
1953
|
+
`${added} ${added === 1 ? "person" : "people"} can now use this reader. `
|
|
1954
|
+
+ "They add their own face, QR or PIN from their app.",
|
|
1955
|
+
"success"
|
|
1956
|
+
);
|
|
1957
|
+
} catch (error: unknown) {
|
|
1958
|
+
showToast(
|
|
1959
|
+
apiReason(error) || "Unable to give these people access to this reader.",
|
|
1960
|
+
"error"
|
|
1961
|
+
);
|
|
1962
|
+
} finally {
|
|
1963
|
+
bulkSaving.value = false;
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
/**
|
|
1968
|
+
* Switching type clears the chosen subject, and nothing else.
|
|
1969
|
+
*
|
|
1970
|
+
* The id spaces do not overlap - a `site.people._id` submitted as a `member`
|
|
1971
|
+
* would be refused by the server, and worse, a stale one could name a real
|
|
1972
|
+
* member - so it is cleared rather than carried. Name and Role mirror the
|
|
1973
|
+
* choice, so they clear with it.
|
|
654
1974
|
*/
|
|
655
|
-
|
|
1975
|
+
async function onSubjectCategoryChanged() {
|
|
1976
|
+
REMEMBERED_SUBJECT_CATEGORY.value = form.subjectCategory as HidEnrolmentSubject;
|
|
1977
|
+
form.subjectId = "";
|
|
1978
|
+
form.subjectRole = "";
|
|
1979
|
+
form.name = "";
|
|
1980
|
+
residentConflict.value = null;
|
|
656
1981
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
1982
|
+
// Each list is fetched on first use and then kept, so flipping between the
|
|
1983
|
+
// three options costs one request each rather than one per flip.
|
|
1984
|
+
if (isMemberSubject.value && !membersLoaded.value) {
|
|
1985
|
+
await loadMemberCandidates();
|
|
1986
|
+
}
|
|
1987
|
+
if (isProviderSubject.value && !providersLoaded.value) {
|
|
1988
|
+
await loadProviderCandidates();
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
/* The staff equivalent of `onResidentChanged`. */
|
|
1993
|
+
async function onMemberChanged() {
|
|
1994
|
+
const candidate = memberCandidates.value.find(
|
|
1995
|
+
(item) => String(item.subjectId) === String(form.subjectId)
|
|
662
1996
|
);
|
|
1997
|
+
form.name = String(candidate?.name ?? "");
|
|
1998
|
+
form.subjectRole = String(candidate?.subtitle ?? "");
|
|
1999
|
+
await checkSubjectEnrollment();
|
|
663
2000
|
}
|
|
664
|
-
|
|
665
|
-
const
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
2001
|
+
|
|
2002
|
+
const blockPlaceholder = computed(() => {
|
|
2003
|
+
if (loadingOccupancy.value) return "Loading...";
|
|
2004
|
+
if (occupancyLoaded.value && !blockOptions.value.length) {
|
|
2005
|
+
return "No occupied units at this site";
|
|
2006
|
+
}
|
|
2007
|
+
return "Select block";
|
|
669
2008
|
});
|
|
670
2009
|
|
|
671
|
-
const
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
photoPreview: "",
|
|
682
|
-
subjectCategory: "resident" as THidPermissionCategory,
|
|
683
|
-
subjectId: "",
|
|
2010
|
+
const unitResidentPlaceholder = computed(() => {
|
|
2011
|
+
if (!form.unit) return "Select a unit first";
|
|
2012
|
+
if (loadingUnitPeople.value) return "Loading residents...";
|
|
2013
|
+
if (enrollableUnitPeople.value.length) return "Select a resident";
|
|
2014
|
+
// The two empty cases are different problems with different fixes, so they
|
|
2015
|
+
// do not share a sentence: nobody lives here, versus nobody here can be
|
|
2016
|
+
// enrolled yet. `unitResidentNote` carries the detail for the second.
|
|
2017
|
+
return unitPeople.value.length
|
|
2018
|
+
? "No resident here can be enrolled yet"
|
|
2019
|
+
: "No residents in this unit";
|
|
684
2020
|
});
|
|
685
2021
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
const
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
})));
|
|
2022
|
+
// Names the HID user that is in the way, so the operator can go and edit it
|
|
2023
|
+
// rather than being told only that something is wrong.
|
|
2024
|
+
const residentConflictMessage = computed(() => {
|
|
2025
|
+
const conflict = residentConflict.value;
|
|
2026
|
+
if (!conflict) return "";
|
|
2027
|
+
const label = [getName(conflict), formatHidUid(conflict.hidUserId)]
|
|
2028
|
+
.filter(Boolean)
|
|
2029
|
+
.join(" · ");
|
|
2030
|
+
return label
|
|
2031
|
+
? `Already enrolled on this reader as ${label}. Edit that HID user instead of enrolling again.`
|
|
2032
|
+
: "Already enrolled on this reader. Edit that HID user instead of enrolling again.";
|
|
2033
|
+
});
|
|
699
2034
|
|
|
700
2035
|
const cardForm = reactive({
|
|
701
2036
|
cardType: "pacs" as "pacs" | "csn",
|
|
@@ -705,23 +2040,37 @@ const cardForm = reactive({
|
|
|
705
2040
|
});
|
|
706
2041
|
|
|
707
2042
|
const cardReader = computed(() => {
|
|
708
|
-
const readerId = String(
|
|
709
|
-
|
|
2043
|
+
const readerId = String(
|
|
2044
|
+
cardUser.value?.reader || selectedReaderId.value || ""
|
|
2045
|
+
);
|
|
2046
|
+
return (
|
|
2047
|
+
readers.value.find((reader) => String(reader._id) === readerId) || null
|
|
2048
|
+
);
|
|
710
2049
|
});
|
|
711
|
-
const cardProfileConfigured = computed(() =>
|
|
712
|
-
|
|
713
|
-
)
|
|
714
|
-
|
|
2050
|
+
const cardProfileConfigured = computed(() =>
|
|
2051
|
+
["pacs", "csn", "csn_for_mifare"].includes(
|
|
2052
|
+
String(cardReader.value?.cardReadingMode || "")
|
|
2053
|
+
)
|
|
2054
|
+
);
|
|
2055
|
+
const hybridCardProfile = computed(
|
|
2056
|
+
() => cardReader.value?.cardReadingMode === "csn_for_mifare"
|
|
2057
|
+
);
|
|
715
2058
|
const hybridCardTypeOptions = [
|
|
716
2059
|
{ title: "MIFARE card (CSN)", value: "csn" },
|
|
717
2060
|
{ title: "Other card (PACS)", value: "pacs" },
|
|
718
2061
|
];
|
|
719
2062
|
const cardProfileLabel = computed(() => {
|
|
720
2063
|
const mode = String(cardReader.value?.cardReadingMode || "unconfigured");
|
|
721
|
-
if (mode === "pacs")
|
|
722
|
-
|
|
2064
|
+
if (mode === "pacs")
|
|
2065
|
+
return `PACS · ${(cardReader.value?.pacsFormat || "raw").toUpperCase()}`;
|
|
2066
|
+
if (mode === "csn")
|
|
2067
|
+
return `CSN · ${(
|
|
2068
|
+
cardReader.value?.csnByteOrder || "msb"
|
|
2069
|
+
).toUpperCase()} first`;
|
|
723
2070
|
if (mode === "csn_for_mifare") {
|
|
724
|
-
return `PACS for non-MIFARE cards · CSN for MIFARE (${(
|
|
2071
|
+
return `PACS for non-MIFARE cards · CSN for MIFARE (${(
|
|
2072
|
+
cardReader.value?.csnByteOrder || "msb"
|
|
2073
|
+
).toUpperCase()} first)`;
|
|
725
2074
|
}
|
|
726
2075
|
return "Configure the card data mode on the HID Reader screen before assigning a card.";
|
|
727
2076
|
});
|
|
@@ -738,6 +2087,8 @@ const userHeaders = [
|
|
|
738
2087
|
{ title: "Name", value: "name", sortable: false },
|
|
739
2088
|
{ title: "Facial Data", value: "facialData", sortable: false },
|
|
740
2089
|
{ title: "UID", value: "hidUserId", sortable: false },
|
|
2090
|
+
// Reads in the same order the dialog asks for them: UID then registration.
|
|
2091
|
+
{ title: "Registration No.", value: "registration", sortable: false },
|
|
741
2092
|
{ title: "Status", value: "status", sortable: false },
|
|
742
2093
|
{ title: "", value: "action-table", sortable: false },
|
|
743
2094
|
];
|
|
@@ -749,8 +2100,12 @@ const cardHeaders = [
|
|
|
749
2100
|
{ title: "", value: "action-table", sortable: false },
|
|
750
2101
|
];
|
|
751
2102
|
|
|
752
|
-
const tableTitle = computed(() =>
|
|
753
|
-
|
|
2103
|
+
const tableTitle = computed(() =>
|
|
2104
|
+
props.cardManagement ? "HID Cards" : "HID Users"
|
|
2105
|
+
);
|
|
2106
|
+
const tableHeaders = computed(() =>
|
|
2107
|
+
props.cardManagement ? cardHeaders : userHeaders
|
|
2108
|
+
);
|
|
754
2109
|
|
|
755
2110
|
/**
|
|
756
2111
|
* The list is paged on the SERVER, so the shared pager only moves the cursor -
|
|
@@ -767,14 +2122,45 @@ function reloadUsersFromFirstPage() {
|
|
|
767
2122
|
loadUsers();
|
|
768
2123
|
}
|
|
769
2124
|
|
|
2125
|
+
/**
|
|
2126
|
+
* Typing is the search.
|
|
2127
|
+
*
|
|
2128
|
+
* The field used to reload on `@keyup.enter` and on nothing else, so a screen
|
|
2129
|
+
* that looks like every other search box did nothing at all unless you guessed
|
|
2130
|
+
* that Enter was required - no request left the browser.
|
|
2131
|
+
*
|
|
2132
|
+
* Debounced, because each reload is a login/load/logout round trip to a reader
|
|
2133
|
+
* over a VPN, not a database query. Enter still works and skips the wait.
|
|
2134
|
+
*/
|
|
2135
|
+
const SEARCH_DEBOUNCE_MS = 400;
|
|
2136
|
+
let searchDebounce: ReturnType<typeof setTimeout> | undefined;
|
|
2137
|
+
|
|
2138
|
+
function onSearchInput() {
|
|
2139
|
+
clearTimeout(searchDebounce);
|
|
2140
|
+
searchDebounce = setTimeout(reloadUsersFromFirstPage, SEARCH_DEBOUNCE_MS);
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
function onSearchSubmit() {
|
|
2144
|
+
clearTimeout(searchDebounce);
|
|
2145
|
+
reloadUsersFromFirstPage();
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
watch(search, onSearchInput);
|
|
2149
|
+
onBeforeUnmount(() => clearTimeout(searchDebounce));
|
|
2150
|
+
|
|
770
2151
|
const readerOptions = computed(() =>
|
|
771
2152
|
readers.value.map((reader) => ({
|
|
772
|
-
title: `${reader.name || reader.deviceId || reader._id} — ${
|
|
2153
|
+
title: `${reader.name || reader.deviceId || reader._id} — ${
|
|
2154
|
+
reader.portalName || "Portal not configured"
|
|
2155
|
+
}`,
|
|
773
2156
|
value: reader._id,
|
|
774
2157
|
}))
|
|
775
2158
|
);
|
|
776
|
-
const selectedReader = computed(
|
|
777
|
-
|
|
2159
|
+
const selectedReader = computed(
|
|
2160
|
+
() =>
|
|
2161
|
+
readers.value.find(
|
|
2162
|
+
(reader) => reader._id === (selectedReaderId.value || form.reader)
|
|
2163
|
+
) || null
|
|
778
2164
|
);
|
|
779
2165
|
|
|
780
2166
|
const pageRange = computed(() => {
|
|
@@ -801,15 +2187,28 @@ async function loadReaders() {
|
|
|
801
2187
|
const response = await getReaders({ site: props.site, page: 1, limit: 100 });
|
|
802
2188
|
const responseRecord = toRecord(response);
|
|
803
2189
|
const responseData = toRecord(responseRecord.data);
|
|
804
|
-
const items =
|
|
2190
|
+
const items =
|
|
2191
|
+
responseRecord.items ?? responseData.items ?? responseData.readers ?? [];
|
|
805
2192
|
readers.value = (Array.isArray(items) ? items : [])
|
|
806
2193
|
.map(toHidReader)
|
|
807
2194
|
.filter((reader): reader is HidReader => reader !== null);
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
2195
|
+
/* Shared with every other HID screen — see `useHidReaderSelection`. The
|
|
2196
|
+
resolver refuses a remembered reader that is not this site's, so a site
|
|
2197
|
+
switch or a deleted reader falls back to the first rather than querying
|
|
2198
|
+
an id the site does not own. */
|
|
2199
|
+
selectedReaderId.value = resolveForSite(props.site, readers.value);
|
|
811
2200
|
}
|
|
812
2201
|
|
|
2202
|
+
/**
|
|
2203
|
+
* Discards a load whose answer arrives after a newer one started.
|
|
2204
|
+
*
|
|
2205
|
+
* Each reload is a slow round trip to a reader over a VPN, and typing now fires
|
|
2206
|
+
* them, so "abc" can have three in flight at once. Without this the reply to
|
|
2207
|
+
* "a" can land last and repopulate the table with the wrong rows. Same monotonic
|
|
2208
|
+
* counter `HidAccessLogDashboard` uses.
|
|
2209
|
+
*/
|
|
2210
|
+
const loadUsersSequence = ref(0);
|
|
2211
|
+
|
|
813
2212
|
async function loadUsers() {
|
|
814
2213
|
if (!selectedReaderId.value) {
|
|
815
2214
|
users.value = [];
|
|
@@ -819,32 +2218,83 @@ async function loadUsers() {
|
|
|
819
2218
|
return;
|
|
820
2219
|
}
|
|
821
2220
|
|
|
2221
|
+
const sequence = loadUsersSequence.value + 1;
|
|
2222
|
+
loadUsersSequence.value = sequence;
|
|
822
2223
|
loading.value = true;
|
|
823
2224
|
try {
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
2225
|
+
// `userType: "user"` is REQUIRED here, not a nicety. The reader keeps
|
|
2226
|
+
// visitors in the same `users` table, marked `user_type_id = 1`, and the
|
|
2227
|
+
// API's default is `all`. Visitor rows are written by our own visitor-QR
|
|
2228
|
+
// issuance and are never removed - revoke only pushes `end_time` into the
|
|
2229
|
+
// past - so without this filter the enrollment roster counts every visitor
|
|
2230
|
+
// pass ever issued and drifts further from the reader's own Users screen
|
|
2231
|
+
// with each one. This screen enrolls and edits real people; visitors are
|
|
2232
|
+
// owned by the visitor module.
|
|
2233
|
+
let clientFiltersVisitors = false;
|
|
2234
|
+
let response: unknown;
|
|
2235
|
+
try {
|
|
2236
|
+
response = await getReaderUsers(selectedReaderId.value, {
|
|
2237
|
+
page: page.value,
|
|
2238
|
+
limit: limit.value,
|
|
2239
|
+
search: search.value.trim(),
|
|
2240
|
+
status: status.value.toLowerCase() as "mapped" | "unmapped" | "",
|
|
2241
|
+
userType: "user",
|
|
2242
|
+
});
|
|
2243
|
+
} catch (error: unknown) {
|
|
2244
|
+
if (!isUnsupportedUserTypeFilter(error)) throw error;
|
|
2245
|
+
|
|
2246
|
+
// A deployed API can lag behind this shared layer. Keep the roster
|
|
2247
|
+
// correct on one that has not added `userType` yet, the same way
|
|
2248
|
+
// HidReaderUserRoster does, rather than failing the whole screen.
|
|
2249
|
+
clientFiltersVisitors = true;
|
|
2250
|
+
response = await getReaderUsers(selectedReaderId.value, {
|
|
2251
|
+
page: 1,
|
|
2252
|
+
limit: 100,
|
|
2253
|
+
search: search.value.trim(),
|
|
2254
|
+
status: status.value.toLowerCase() as "mapped" | "unmapped" | "",
|
|
2255
|
+
});
|
|
2256
|
+
}
|
|
830
2257
|
const responseRecord = toRecord(response);
|
|
831
2258
|
const responseData = toRecord(responseRecord.data);
|
|
832
2259
|
const responseItems = responseRecord.items ?? responseData.items ?? [];
|
|
833
|
-
const
|
|
2260
|
+
const allItems = (Array.isArray(responseItems) ? responseItems : []).map(
|
|
2261
|
+
toHidUser
|
|
2262
|
+
);
|
|
2263
|
+
const matchingItems = clientFiltersVisitors
|
|
2264
|
+
? allItems.filter((user) => !isVisitorUser(user))
|
|
2265
|
+
: allItems;
|
|
2266
|
+
const pageStart = (page.value - 1) * limit.value;
|
|
2267
|
+
const pageItems = clientFiltersVisitors
|
|
2268
|
+
? matchingItems.slice(pageStart, pageStart + limit.value)
|
|
2269
|
+
: matchingItems;
|
|
2270
|
+
if (sequence !== loadUsersSequence.value) return;
|
|
834
2271
|
administratorUserIds.value = new Set(
|
|
835
2272
|
pageItems
|
|
836
2273
|
.filter((user) => user.isAdministrator === true)
|
|
837
2274
|
.map((user) => toHidNumericId(user.hidUserId))
|
|
838
|
-
.filter((userId: number | undefined): userId is number =>
|
|
2275
|
+
.filter((userId: number | undefined): userId is number =>
|
|
2276
|
+
Boolean(userId)
|
|
2277
|
+
)
|
|
839
2278
|
);
|
|
840
2279
|
users.value = pageItems;
|
|
841
|
-
total.value =
|
|
842
|
-
|
|
843
|
-
|
|
2280
|
+
total.value = clientFiltersVisitors
|
|
2281
|
+
? matchingItems.length
|
|
2282
|
+
: Number(
|
|
2283
|
+
responseRecord.total ?? responseData.total ?? users.value.length
|
|
2284
|
+
);
|
|
2285
|
+
pages.value = clientFiltersVisitors
|
|
2286
|
+
? Math.max(1, Math.ceil(total.value / limit.value))
|
|
2287
|
+
: Number(responseRecord.pages ?? responseData.pages ?? 1);
|
|
2288
|
+
// The server's `pageRange` counts the unfiltered page, so it cannot be
|
|
2289
|
+
// trusted once this screen has done the filtering itself.
|
|
2290
|
+
serverPageRange.value = clientFiltersVisitors
|
|
2291
|
+
? ""
|
|
2292
|
+
: String(responseRecord.pageRange ?? responseData.pageRange ?? "");
|
|
844
2293
|
if (props.cardManagement) {
|
|
845
2294
|
await loadVisibleUserCards(users.value);
|
|
846
2295
|
}
|
|
847
2296
|
} catch (error) {
|
|
2297
|
+
if (sequence !== loadUsersSequence.value) return;
|
|
848
2298
|
console.error("Unable to load HID reader users:", error);
|
|
849
2299
|
showToast("Unable to load HID users from reader.", "error");
|
|
850
2300
|
users.value = [];
|
|
@@ -855,11 +2305,12 @@ async function loadUsers() {
|
|
|
855
2305
|
pages.value = 1;
|
|
856
2306
|
serverPageRange.value = "";
|
|
857
2307
|
} finally {
|
|
858
|
-
|
|
2308
|
+
// Only the newest load owns the spinner; an older one finishing must not
|
|
2309
|
+
// clear it while that newer one is still running.
|
|
2310
|
+
if (sequence === loadUsersSequence.value) loading.value = false;
|
|
859
2311
|
}
|
|
860
2312
|
}
|
|
861
2313
|
|
|
862
|
-
|
|
863
2314
|
function resetForm() {
|
|
864
2315
|
form.reader = selectedReaderId.value || readers.value[0]?._id || "";
|
|
865
2316
|
form.block = "";
|
|
@@ -870,6 +2321,10 @@ function resetForm() {
|
|
|
870
2321
|
form.registration = "";
|
|
871
2322
|
form.isAdministrator = false;
|
|
872
2323
|
form.accessPin = "";
|
|
2324
|
+
form.password = "";
|
|
2325
|
+
passwordSet.value = false;
|
|
2326
|
+
showPassword.value = false;
|
|
2327
|
+
removePasswordRequested.value = false;
|
|
873
2328
|
pinEnrolled.value = false;
|
|
874
2329
|
showPin.value = false;
|
|
875
2330
|
removePinRequested.value = false;
|
|
@@ -877,34 +2332,201 @@ function resetForm() {
|
|
|
877
2332
|
selectedPhotoFile.value = null;
|
|
878
2333
|
removePhotoRequested.value = false;
|
|
879
2334
|
facialEnrolled.value = false;
|
|
880
|
-
|
|
2335
|
+
// The type the operator last enrolled, not a hard "resident": somebody adding
|
|
2336
|
+
// a shift of guards should not re-pick Member for each one.
|
|
2337
|
+
form.subjectCategory = REMEMBERED_SUBJECT_CATEGORY.value;
|
|
881
2338
|
form.subjectId = "";
|
|
2339
|
+
form.subjectRole = "";
|
|
2340
|
+
residentConflict.value = null;
|
|
2341
|
+
checkingResident.value = false;
|
|
882
2342
|
}
|
|
883
2343
|
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
2344
|
+
/** Surface what the API actually said - a bare "unable to load" hides the
|
|
2345
|
+
* difference between a permission refusal, a bad id and a server fault. */
|
|
2346
|
+
function apiReason(error: unknown) {
|
|
2347
|
+
const record = toRecord(error);
|
|
2348
|
+
const data = toRecord(record.data);
|
|
2349
|
+
return (
|
|
2350
|
+
toText(data.message) ||
|
|
2351
|
+
toText(record.message) ||
|
|
2352
|
+
toText(record.statusMessage)
|
|
2353
|
+
);
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
// Called when the Block select is first touched, not when the dialog opens.
|
|
2357
|
+
async function ensureOccupancy() {
|
|
2358
|
+
if (!props.site || occupancyLoaded.value || loadingOccupancy.value) return;
|
|
2359
|
+
loadingOccupancy.value = true;
|
|
2360
|
+
try {
|
|
2361
|
+
const response = toRecord(
|
|
2362
|
+
await getOccupiedStructure(props.site, {
|
|
2363
|
+
type: OCCUPANT_TYPES,
|
|
2364
|
+
status: "active",
|
|
2365
|
+
})
|
|
2366
|
+
);
|
|
2367
|
+
const data = toRecord(response.data);
|
|
2368
|
+
const blocks = response.blocks ?? data.blocks ?? [];
|
|
2369
|
+
occupiedBlocks.value = (
|
|
2370
|
+
Array.isArray(blocks) ? blocks : []
|
|
2371
|
+
) as OccupiedBlock[];
|
|
2372
|
+
occupancyLoaded.value = true;
|
|
2373
|
+
} catch (error: unknown) {
|
|
2374
|
+
console.error("Unable to load occupied units:", error);
|
|
2375
|
+
occupiedBlocks.value = [];
|
|
2376
|
+
const reason = apiReason(error);
|
|
2377
|
+
showToast(
|
|
2378
|
+
reason
|
|
2379
|
+
? `Unable to load units: ${reason}`
|
|
2380
|
+
: "Unable to load units for this site.",
|
|
2381
|
+
"error"
|
|
2382
|
+
);
|
|
2383
|
+
} finally {
|
|
2384
|
+
loadingOccupancy.value = false;
|
|
889
2385
|
}
|
|
890
|
-
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
async function loadUnitPeople() {
|
|
2389
|
+
unitPeople.value = [];
|
|
2390
|
+
if (!form.unit) return;
|
|
2391
|
+
loadingUnitPeople.value = true;
|
|
891
2392
|
try {
|
|
892
|
-
const response = await
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
category: form.subjectCategory,
|
|
896
|
-
page: 1,
|
|
897
|
-
limit: 500,
|
|
2393
|
+
const response = await getPeopleByUnit(form.unit, {
|
|
2394
|
+
status: "active",
|
|
2395
|
+
type: OCCUPANT_TYPES,
|
|
898
2396
|
});
|
|
899
|
-
|
|
2397
|
+
const record = toRecord(response);
|
|
2398
|
+
const rows = Array.isArray(response)
|
|
2399
|
+
? response
|
|
2400
|
+
: record.data ?? record.items ?? [];
|
|
2401
|
+
unitPeople.value = (Array.isArray(rows) ? rows : []) as UnitPerson[];
|
|
2402
|
+
} catch (error: unknown) {
|
|
2403
|
+
console.error("Unable to load residents for the unit:", error);
|
|
2404
|
+
const reason = apiReason(error);
|
|
2405
|
+
showToast(
|
|
2406
|
+
reason
|
|
2407
|
+
? `Unable to load residents: ${reason}`
|
|
2408
|
+
: "Unable to load residents for this unit.",
|
|
2409
|
+
"error"
|
|
2410
|
+
);
|
|
900
2411
|
} finally {
|
|
901
|
-
|
|
2412
|
+
loadingUnitPeople.value = false;
|
|
902
2413
|
}
|
|
903
2414
|
}
|
|
904
2415
|
|
|
905
|
-
|
|
2416
|
+
function onBlockChanged() {
|
|
2417
|
+
form.level = "";
|
|
2418
|
+
form.unit = "";
|
|
2419
|
+
unitPeople.value = [];
|
|
2420
|
+
clearResidentSelection();
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
// Wrapper `@click`/`@focusin` on the Block select: load once, on first touch.
|
|
2424
|
+
async function onBlockOpened() {
|
|
2425
|
+
await ensureOccupancy();
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
function onLevelChanged() {
|
|
2429
|
+
form.unit = "";
|
|
2430
|
+
unitPeople.value = [];
|
|
2431
|
+
clearResidentSelection();
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
async function onUnitChanged() {
|
|
2435
|
+
clearResidentSelection();
|
|
2436
|
+
await loadUnitPeople();
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
function clearResidentSelection() {
|
|
906
2440
|
form.subjectId = "";
|
|
907
|
-
|
|
2441
|
+
residentConflict.value = null;
|
|
2442
|
+
// Name only ever mirrors a chosen resident, so it clears with the choice.
|
|
2443
|
+
if (!selectedUser.value) form.name = "";
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
// The auto-fill the whole cascade exists for.
|
|
2447
|
+
async function onResidentChanged() {
|
|
2448
|
+
const person = unitPeople.value.find(
|
|
2449
|
+
(item) => String(item._id) === String(form.subjectId)
|
|
2450
|
+
);
|
|
2451
|
+
form.name = String(person?.name ?? "");
|
|
2452
|
+
await checkSubjectEnrollment();
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
/**
|
|
2456
|
+
* The HID user this resident already holds on the selected reader, or null.
|
|
2457
|
+
*
|
|
2458
|
+
* `subject`, not `person`. The same resident is stored under different ids
|
|
2459
|
+
* depending on which screen wrote the row - the permissions screen keys on
|
|
2460
|
+
* their user account, this screen on the record the picker offered - so a
|
|
2461
|
+
* filter on one field alone reports "not enrolled" for somebody who plainly is.
|
|
2462
|
+
* `subject` follows the record to its account and matches every link field,
|
|
2463
|
+
* which is exactly what the server does before it accepts an enrollment.
|
|
2464
|
+
*
|
|
2465
|
+
* Reader-scoped by the endpoint itself.
|
|
2466
|
+
*/
|
|
2467
|
+
async function findResidentIdentity(readerId: string, personId: string) {
|
|
2468
|
+
const response = await getIdentities(readerId, {
|
|
2469
|
+
page: 1,
|
|
2470
|
+
// A resident should hold at most one, but ask for a few: on the edit screen
|
|
2471
|
+
// the first row back can be the very identity being edited, and skipping it
|
|
2472
|
+
// must not hide a real second one behind it.
|
|
2473
|
+
limit: 10,
|
|
2474
|
+
subject: personId,
|
|
2475
|
+
});
|
|
2476
|
+
const responseRecord = toRecord(response);
|
|
2477
|
+
const responseData = toRecord(responseRecord.data);
|
|
2478
|
+
const responseItems =
|
|
2479
|
+
responseRecord.items ?? responseData.items ?? responseData.identities ?? [];
|
|
2480
|
+
const identities = (Array.isArray(responseItems) ? responseItems : []).map(
|
|
2481
|
+
toHidUser
|
|
2482
|
+
);
|
|
2483
|
+
const editingId = String(selectedUser.value?._id ?? "");
|
|
2484
|
+
|
|
2485
|
+
return (
|
|
2486
|
+
identities.find(
|
|
2487
|
+
(identity) => !editingId || String(identity._id ?? "") !== editingId
|
|
2488
|
+
) ?? null
|
|
2489
|
+
);
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
/**
|
|
2493
|
+
* Sets `residentConflict` for the currently selected resident.
|
|
2494
|
+
*
|
|
2495
|
+
* Fails OPEN: if the lookup itself errors the operator is not blocked, because
|
|
2496
|
+
* the server refuses the duplicate anyway. This is a courtesy check that keeps
|
|
2497
|
+
* a doomed enrollment off the physical reader, not the guard itself.
|
|
2498
|
+
*/
|
|
2499
|
+
/**
|
|
2500
|
+
* Is this subject already enrolled on this reader?
|
|
2501
|
+
*
|
|
2502
|
+
* Category-agnostic, since staff can be enrolled too. `findResidentIdentity`
|
|
2503
|
+
* queries the server's `subject` parameter, which is matched against every link
|
|
2504
|
+
* field - `person`, `member`, `serviceProvider`, `user` - so a member needs no
|
|
2505
|
+
* separate lookup. The server refuses a duplicate either way; this only puts the
|
|
2506
|
+
* refusal in front of the operator before the device is touched.
|
|
2507
|
+
*/
|
|
2508
|
+
async function checkSubjectEnrollment() {
|
|
2509
|
+
residentConflict.value = null;
|
|
2510
|
+
const readerId =
|
|
2511
|
+
selectedReaderId.value || form.reader || readers.value[0]?._id;
|
|
2512
|
+
if (!readerId || !form.subjectId) return null;
|
|
2513
|
+
|
|
2514
|
+
checkingResident.value = true;
|
|
2515
|
+
try {
|
|
2516
|
+
residentConflict.value = await findResidentIdentity(
|
|
2517
|
+
readerId,
|
|
2518
|
+
form.subjectId
|
|
2519
|
+
);
|
|
2520
|
+
return residentConflict.value;
|
|
2521
|
+
} catch (error: unknown) {
|
|
2522
|
+
console.error(
|
|
2523
|
+
"Unable to check whether this resident is already enrolled:",
|
|
2524
|
+
error
|
|
2525
|
+
);
|
|
2526
|
+
return null;
|
|
2527
|
+
} finally {
|
|
2528
|
+
checkingResident.value = false;
|
|
2529
|
+
}
|
|
908
2530
|
}
|
|
909
2531
|
|
|
910
2532
|
async function openEnroll() {
|
|
@@ -916,8 +2538,16 @@ async function openEnroll() {
|
|
|
916
2538
|
]);
|
|
917
2539
|
form.hidUserId = hidUserId;
|
|
918
2540
|
form.registration = registration;
|
|
919
|
-
await loadSubjectCandidates();
|
|
920
2541
|
formDialog.value = true;
|
|
2542
|
+
|
|
2543
|
+
// `resetForm` restores the remembered type, so the dialog can open straight
|
|
2544
|
+
// onto Member with an empty list. Loaded after the dialog is up, not before:
|
|
2545
|
+
// the request must not delay it, and the picker carries its own loading
|
|
2546
|
+
// placeholder. Fresh each opening, because staff change between them.
|
|
2547
|
+
resetMemberCandidates();
|
|
2548
|
+
resetProviderCandidates();
|
|
2549
|
+
if (isMemberSubject.value) await loadMemberCandidates();
|
|
2550
|
+
if (isProviderSubject.value) await loadProviderCandidates();
|
|
921
2551
|
}
|
|
922
2552
|
|
|
923
2553
|
async function openEdit(user: HidUser) {
|
|
@@ -931,31 +2561,59 @@ async function openEdit(user: HidUser) {
|
|
|
931
2561
|
form.registration = user.registration ?? "";
|
|
932
2562
|
form.isAdministrator = hasAdministratorRule(user);
|
|
933
2563
|
form.accessPin = "";
|
|
2564
|
+
form.password = "";
|
|
2565
|
+
showPassword.value = false;
|
|
2566
|
+
removePasswordRequested.value = false;
|
|
2567
|
+
passwordSet.value = Boolean(user.metadata?.passwordSet);
|
|
934
2568
|
pinEnrolled.value = Boolean(user.metadata?.pinEnrolled);
|
|
935
2569
|
removePinRequested.value = false;
|
|
936
2570
|
form.photoPreview = "";
|
|
937
2571
|
facialEnrolled.value = hasFacialEnrollment(user);
|
|
2572
|
+
/*
|
|
2573
|
+
* Read in the order `createIdentity` validates the links, with one extra
|
|
2574
|
+
* test. A `member` link is how BOTH a property-management member and a
|
|
2575
|
+
* service provider's own employee are stored - the person's membership at
|
|
2576
|
+
* this site - so the identity's `type` is what tells them apart. Without
|
|
2577
|
+
* that test a contractor's edit would be headed "Member".
|
|
2578
|
+
*/
|
|
938
2579
|
form.subjectCategory = user.person
|
|
939
2580
|
? "resident"
|
|
940
2581
|
: user.serviceProvider
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
2582
|
+
? "service_provider"
|
|
2583
|
+
: user.member && String(user.type ?? "") === "contractor"
|
|
2584
|
+
? "service_provider_staff"
|
|
2585
|
+
: "property_management";
|
|
2586
|
+
form.subjectId = String(
|
|
2587
|
+
user.person || user.member || user.serviceProvider || ""
|
|
2588
|
+
);
|
|
2589
|
+
// The resident this user already holds is not a conflict with itself. A real
|
|
2590
|
+
// one only appears if the operator reassigns the row to somebody else.
|
|
2591
|
+
residentConflict.value = null;
|
|
2592
|
+
// The location is not drawn when editing, but it is still carried: these
|
|
2593
|
+
// three form fields are what `saveUser` writes back and what `buildUnitLabel`
|
|
2594
|
+
// resolves into the Unit column, so a save must not blank them.
|
|
2595
|
+
//
|
|
2596
|
+
// `ensureOccupancy` is what lets `buildUnitLabel` turn them back into names.
|
|
2597
|
+
// The unit's residents are loaded too, because the Resident select IS drawn
|
|
2598
|
+
// here and its options come from that list - without it the select shows the
|
|
2599
|
+
// stored id instead of a name.
|
|
2600
|
+
await ensureOccupancy();
|
|
2601
|
+
if (form.unit) await loadUnitPeople();
|
|
945
2602
|
selectedPhotoFile.value = null;
|
|
946
2603
|
removePhotoRequested.value = false;
|
|
947
2604
|
formDialog.value = true;
|
|
948
2605
|
|
|
949
2606
|
try {
|
|
950
|
-
const
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
2607
|
+
const hidUserId = toHidNumericId(user.hidUserId);
|
|
2608
|
+
const [pinStatus, passwordStatus] = await Promise.all([
|
|
2609
|
+
getUserPinStatus(form.reader, hidUserId),
|
|
2610
|
+
getUserPasswordStatus(form.reader, hidUserId),
|
|
2611
|
+
]);
|
|
2612
|
+
pinEnrolled.value = Boolean(pinStatus?.data?.pinEnrolled);
|
|
2613
|
+
passwordSet.value = Boolean(passwordStatus?.data?.passwordSet);
|
|
955
2614
|
} catch {
|
|
956
2615
|
// Preserve the last known status if the reader is temporarily offline.
|
|
957
2616
|
}
|
|
958
|
-
|
|
959
2617
|
}
|
|
960
2618
|
|
|
961
2619
|
function openView(user: HidUser) {
|
|
@@ -1030,16 +2688,55 @@ function onPhotoChange(event: Event) {
|
|
|
1030
2688
|
async function saveUser() {
|
|
1031
2689
|
const readerId =
|
|
1032
2690
|
selectedReaderId.value || form.reader || readers.value[0]?._id;
|
|
1033
|
-
|
|
1034
|
-
|
|
2691
|
+
// A resident is required to ENROL, because an identity has to be linked to
|
|
2692
|
+
// somebody. It is not required to edit: the reader holds users we never
|
|
2693
|
+
// enrolled, and changing a photo, a PIN or a password on one of those is a
|
|
2694
|
+
// reader-side operation that has nothing to say about who they are. Demanding
|
|
2695
|
+
// a resident there refuses a save for a field the dialog does not even draw.
|
|
2696
|
+
const missing = !readerId
|
|
2697
|
+
? "a reader"
|
|
2698
|
+
: !selectedUser.value && !form.subjectId
|
|
2699
|
+
? isResidentSubject.value
|
|
2700
|
+
? "a resident"
|
|
2701
|
+
: isProviderSubject.value
|
|
2702
|
+
? "a person"
|
|
2703
|
+
: "a member"
|
|
2704
|
+
: !form.name
|
|
2705
|
+
? "a name"
|
|
2706
|
+
: !form.hidUserId
|
|
2707
|
+
? "an ID"
|
|
2708
|
+
: !form.registration
|
|
2709
|
+
? "a registration number"
|
|
2710
|
+
: "";
|
|
2711
|
+
if (missing) {
|
|
2712
|
+
showToast(
|
|
2713
|
+
`This HID user needs ${missing} before it can be saved.`,
|
|
2714
|
+
"error"
|
|
2715
|
+
);
|
|
1035
2716
|
return;
|
|
1036
2717
|
}
|
|
1037
2718
|
if (form.accessPin && !/^\d{1,32}$/.test(form.accessPin)) {
|
|
1038
|
-
showToast("
|
|
2719
|
+
showToast("PIN must contain numbers only.", "error");
|
|
2720
|
+
return;
|
|
2721
|
+
}
|
|
2722
|
+
if (
|
|
2723
|
+
selectedPhotoFile.value &&
|
|
2724
|
+
(!selectedReader.value?.portalId || !selectedReader.value?.portalName)
|
|
2725
|
+
) {
|
|
2726
|
+
showToast(
|
|
2727
|
+
"Reconnect this HID reader and select a portal before enrolling facial recognition.",
|
|
2728
|
+
"error"
|
|
2729
|
+
);
|
|
1039
2730
|
return;
|
|
1040
2731
|
}
|
|
1041
|
-
|
|
1042
|
-
|
|
2732
|
+
|
|
2733
|
+
// Re-asked here, not trusted from the select: the dialog can sit open while
|
|
2734
|
+
// somebody else enrolls the same resident, and the reader is only ever
|
|
2735
|
+
// written to below this line. A duplicate caught here costs one request; the
|
|
2736
|
+
// same duplicate caught by the server costs a created HID user and a rollback.
|
|
2737
|
+
const alreadyEnrolled = await checkSubjectEnrollment();
|
|
2738
|
+
if (alreadyEnrolled) {
|
|
2739
|
+
showToast(residentConflictMessage.value, "error");
|
|
1043
2740
|
return;
|
|
1044
2741
|
}
|
|
1045
2742
|
|
|
@@ -1056,22 +2753,17 @@ async function saveUser() {
|
|
|
1056
2753
|
return;
|
|
1057
2754
|
}
|
|
1058
2755
|
|
|
2756
|
+
// The link is sent only when the form actually holds one. Sending
|
|
2757
|
+
// `person: ""` is not "leave it alone", it is "clear it": the server reads
|
|
2758
|
+
// an empty string as a link being removed and then refuses the write for
|
|
2759
|
+
// having no subject at all. Omitting the keys is what makes it keep the
|
|
2760
|
+
// subject the record already has.
|
|
2761
|
+
const subjectLink = subjectLinkOf(form.subjectCategory, form.subjectId);
|
|
2762
|
+
|
|
1059
2763
|
const payload = {
|
|
1060
2764
|
hidUserId: String(hidUser.id),
|
|
1061
2765
|
registration: form.registration,
|
|
1062
|
-
|
|
1063
|
-
member: form.subjectCategory === "property_management" ? form.subjectId : "",
|
|
1064
|
-
serviceProvider: form.subjectCategory === "service_provider" ? form.subjectId : "",
|
|
1065
|
-
type: (form.subjectCategory === "resident"
|
|
1066
|
-
? "resident"
|
|
1067
|
-
: form.subjectCategory === "service_provider"
|
|
1068
|
-
? "contractor"
|
|
1069
|
-
: "staff") as
|
|
1070
|
-
| "resident"
|
|
1071
|
-
| "staff"
|
|
1072
|
-
| "contractor"
|
|
1073
|
-
| "visitor"
|
|
1074
|
-
| "unknown",
|
|
2766
|
+
...subjectLink,
|
|
1075
2767
|
status: "active" as const,
|
|
1076
2768
|
metadata: {
|
|
1077
2769
|
name: form.name,
|
|
@@ -1088,6 +2780,7 @@ async function saveUser() {
|
|
|
1088
2780
|
imageTimestamp: selectedUser.value?.metadata?.imageTimestamp || "",
|
|
1089
2781
|
facialScores: selectedUser.value?.metadata?.facialScores || {},
|
|
1090
2782
|
pinEnrolled: pinEnrolled.value,
|
|
2783
|
+
passwordSet: passwordSet.value,
|
|
1091
2784
|
hidPhysicalCards: selectedUser.value?.metadata?.hidPhysicalCards || [],
|
|
1092
2785
|
},
|
|
1093
2786
|
};
|
|
@@ -1097,17 +2790,22 @@ async function saveUser() {
|
|
|
1097
2790
|
const facialResult = await syncFacialImage(readerId, hidUser.id);
|
|
1098
2791
|
applyFacialResult(payload.metadata, facialResult);
|
|
1099
2792
|
payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
|
|
2793
|
+
payload.metadata.passwordSet = await syncPassword(readerId, hidUser.id);
|
|
1100
2794
|
if (selectedUser.value._id) {
|
|
1101
2795
|
await updateIdentity(selectedUser.value._id, payload);
|
|
1102
|
-
} else {
|
|
2796
|
+
} else if (form.subjectId) {
|
|
1103
2797
|
await saveIdentityOnReader(readerId, payload);
|
|
1104
2798
|
}
|
|
2799
|
+
// else: a user the reader holds and we have never linked. The photo, PIN
|
|
2800
|
+
// and password above are already on the device; creating an identity for
|
|
2801
|
+
// it would need a subject we deliberately no longer ask for here.
|
|
1105
2802
|
} else {
|
|
1106
2803
|
const created = await createHidUserOnReader(readerId, hidUser);
|
|
1107
2804
|
if (created) newlyCreatedHidUserId = hidUser.id;
|
|
1108
2805
|
const facialResult = await syncFacialImage(readerId, hidUser.id);
|
|
1109
2806
|
applyFacialResult(payload.metadata, facialResult);
|
|
1110
2807
|
payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
|
|
2808
|
+
payload.metadata.passwordSet = await syncPassword(readerId, hidUser.id);
|
|
1111
2809
|
await saveIdentityOnReader(readerId, payload);
|
|
1112
2810
|
selectedReaderId.value = readerId;
|
|
1113
2811
|
}
|
|
@@ -1128,7 +2826,10 @@ async function saveUser() {
|
|
|
1128
2826
|
where: { users: { id: newlyCreatedHidUserId } },
|
|
1129
2827
|
});
|
|
1130
2828
|
} catch (rollbackError) {
|
|
1131
|
-
console.error(
|
|
2829
|
+
console.error(
|
|
2830
|
+
"Unable to roll back the partially enrolled HID user:",
|
|
2831
|
+
rollbackError
|
|
2832
|
+
);
|
|
1132
2833
|
}
|
|
1133
2834
|
}
|
|
1134
2835
|
console.error("Unable to save HID user:", error);
|
|
@@ -1143,6 +2844,25 @@ async function saveUser() {
|
|
|
1143
2844
|
}
|
|
1144
2845
|
}
|
|
1145
2846
|
|
|
2847
|
+
async function syncPassword(readerId: string, hidUserId: number) {
|
|
2848
|
+
// Mirrors syncAccessPin. Removal wins over a typed value, and a blank field
|
|
2849
|
+
// means "leave whatever is on the reader alone" - the hash cannot be read
|
|
2850
|
+
// back, so blank can never mean "clear it".
|
|
2851
|
+
if (removePasswordRequested.value) {
|
|
2852
|
+
await deleteUserPassword(readerId, hidUserId);
|
|
2853
|
+
passwordSet.value = false;
|
|
2854
|
+
form.password = "";
|
|
2855
|
+
return false;
|
|
2856
|
+
}
|
|
2857
|
+
if (form.password) {
|
|
2858
|
+
await setUserPassword(readerId, hidUserId, form.password);
|
|
2859
|
+
form.password = "";
|
|
2860
|
+
passwordSet.value = true;
|
|
2861
|
+
return true;
|
|
2862
|
+
}
|
|
2863
|
+
return passwordSet.value;
|
|
2864
|
+
}
|
|
2865
|
+
|
|
1146
2866
|
async function syncAccessPin(readerId: string, hidUserId: number) {
|
|
1147
2867
|
if (removePinRequested.value) {
|
|
1148
2868
|
await deleteUserPin(readerId, hidUserId);
|
|
@@ -1210,11 +2930,59 @@ function applyFacialResult(
|
|
|
1210
2930
|
}
|
|
1211
2931
|
}
|
|
1212
2932
|
|
|
2933
|
+
/**
|
|
2934
|
+
* THE IDENTITY ROW BEHIND A ROSTER ROW.
|
|
2935
|
+
*
|
|
2936
|
+
* This list is read from the READER (`getReaderUsers`), and `listReaderUsers`
|
|
2937
|
+
* builds each row out of the device's own `users` columns - it carries no
|
|
2938
|
+
* identity `_id` at all, so `toHidUser` leaves `_id` undefined for every row on
|
|
2939
|
+
* screen. A delete that only acted when `_id` was present therefore never
|
|
2940
|
+
* touched the database: the device user was destroyed and the identity stayed
|
|
2941
|
+
* `active`, with no error, because the guard was an `if` rather than a failure.
|
|
2942
|
+
*
|
|
2943
|
+
* That left the row orphaned, and an orphan is not inert. `findIdentityBySubject`
|
|
2944
|
+
* excludes only `status: "deleted"`, so it still answers "this person is already
|
|
2945
|
+
* enrolled on this reader" and REFUSES to enrol somebody the operator can no
|
|
2946
|
+
* longer see. The reconcile reads it too: `findProfileIdentity` finds the active
|
|
2947
|
+
* row and reuses its stored `hidUserId`, so the device user is rebuilt with the
|
|
2948
|
+
* same UID the operator just deleted, from an access grant that was never
|
|
2949
|
+
* removed because `deleteIdentity` never ran.
|
|
2950
|
+
*
|
|
2951
|
+
* Resolved the same way `saveUser` already resolves it, by the reader's own keys.
|
|
2952
|
+
*/
|
|
2953
|
+
async function findIdentityIdForUser(readerId: string, user: HidUser) {
|
|
2954
|
+
if (user._id) return user._id;
|
|
2955
|
+
if (!readerId) return "";
|
|
2956
|
+
try {
|
|
2957
|
+
const identity = await findExistingIdentityOnReader(readerId, {
|
|
2958
|
+
hidUserId: String(user.hidUserId ?? ""),
|
|
2959
|
+
registration: String(user.registration ?? ""),
|
|
2960
|
+
cardNo: String(user.cardNo ?? ""),
|
|
2961
|
+
metadata: {},
|
|
2962
|
+
});
|
|
2963
|
+
return identity?._id ?? "";
|
|
2964
|
+
} catch (error: unknown) {
|
|
2965
|
+
// A lookup failure must not stop the device delete the operator asked for.
|
|
2966
|
+
// The row stays behind and the next delete can clear it.
|
|
2967
|
+
console.error(
|
|
2968
|
+
"Unable to resolve the stored identity for this HID user:",
|
|
2969
|
+
error
|
|
2970
|
+
);
|
|
2971
|
+
return "";
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2974
|
+
|
|
1213
2975
|
async function deleteUser() {
|
|
1214
2976
|
if (!selectedUser.value) return;
|
|
1215
2977
|
|
|
1216
2978
|
deleting.value = true;
|
|
1217
2979
|
try {
|
|
2980
|
+
// Resolved BEFORE the device user is destroyed: this reads our own records,
|
|
2981
|
+
// and doing it first means a failure here costs nothing.
|
|
2982
|
+
const identityId = await findIdentityIdForUser(
|
|
2983
|
+
selectedReaderId.value,
|
|
2984
|
+
selectedUser.value
|
|
2985
|
+
);
|
|
1218
2986
|
await syncHidUserRole(
|
|
1219
2987
|
selectedReaderId.value,
|
|
1220
2988
|
toHidNumericId(selectedUser.value.hidUserId),
|
|
@@ -1222,11 +2990,13 @@ async function deleteUser() {
|
|
|
1222
2990
|
);
|
|
1223
2991
|
await deleteUserPin(
|
|
1224
2992
|
selectedReaderId.value,
|
|
1225
|
-
toHidNumericId(selectedUser.value.hidUserId)
|
|
2993
|
+
toHidNumericId(selectedUser.value.hidUserId)
|
|
1226
2994
|
);
|
|
1227
2995
|
await deleteHidUserFromReader(selectedReaderId.value, selectedUser.value);
|
|
1228
|
-
if (
|
|
1229
|
-
|
|
2996
|
+
if (identityId) {
|
|
2997
|
+
// Soft-deletes the row AND removes the access grant that let this person
|
|
2998
|
+
// through, which is what stops the next reconcile rebuilding them.
|
|
2999
|
+
await deleteIdentity(identityId);
|
|
1230
3000
|
}
|
|
1231
3001
|
deleteDialog.value = false;
|
|
1232
3002
|
await loadUsers();
|
|
@@ -1241,7 +3011,7 @@ async function deleteUser() {
|
|
|
1241
3011
|
|
|
1242
3012
|
async function saveIdentityOnReader(
|
|
1243
3013
|
readerId: string,
|
|
1244
|
-
payload: HidIdentityPayload
|
|
3014
|
+
payload: HidIdentityPayload
|
|
1245
3015
|
) {
|
|
1246
3016
|
const existingIdentity = await findExistingIdentityOnReader(
|
|
1247
3017
|
readerId,
|
|
@@ -1253,7 +3023,11 @@ async function saveIdentityOnReader(
|
|
|
1253
3023
|
}
|
|
1254
3024
|
|
|
1255
3025
|
try {
|
|
1256
|
-
await createIdentity(readerId, {
|
|
3026
|
+
await createIdentity(readerId, {
|
|
3027
|
+
...payload,
|
|
3028
|
+
site: props.site,
|
|
3029
|
+
...(props.org ? { orgId: props.org } : {}),
|
|
3030
|
+
});
|
|
1257
3031
|
} catch (error: unknown) {
|
|
1258
3032
|
const message = getHidErrorMessage(error);
|
|
1259
3033
|
if (!message.toLowerCase().includes("already exists")) throw error;
|
|
@@ -1269,7 +3043,7 @@ async function saveIdentityOnReader(
|
|
|
1269
3043
|
|
|
1270
3044
|
async function findExistingIdentityOnReader(
|
|
1271
3045
|
readerId: string,
|
|
1272
|
-
payload: HidIdentityPayload
|
|
3046
|
+
payload: HidIdentityPayload
|
|
1273
3047
|
) {
|
|
1274
3048
|
const response = await getIdentities(readerId, {
|
|
1275
3049
|
page: 1,
|
|
@@ -1278,8 +3052,11 @@ async function findExistingIdentityOnReader(
|
|
|
1278
3052
|
});
|
|
1279
3053
|
const responseRecord = toRecord(response);
|
|
1280
3054
|
const responseData = toRecord(responseRecord.data);
|
|
1281
|
-
const responseItems =
|
|
1282
|
-
|
|
3055
|
+
const responseItems =
|
|
3056
|
+
responseRecord.items ?? responseData.items ?? responseData.identities ?? [];
|
|
3057
|
+
const identities = (Array.isArray(responseItems) ? responseItems : []).map(
|
|
3058
|
+
toHidUser
|
|
3059
|
+
);
|
|
1283
3060
|
const hidUserId = String(payload.hidUserId || "");
|
|
1284
3061
|
const registration = String(payload.registration || "");
|
|
1285
3062
|
const cardNo = String(payload.cardNo || "");
|
|
@@ -1296,7 +3073,7 @@ async function findExistingIdentityOnReader(
|
|
|
1296
3073
|
|
|
1297
3074
|
async function createHidUserOnReader(
|
|
1298
3075
|
readerId: string,
|
|
1299
|
-
user: { id?: number; name: string; registration: string }
|
|
3076
|
+
user: { id?: number; name: string; registration: string }
|
|
1300
3077
|
) {
|
|
1301
3078
|
const existing = await getHidUserFromReader(readerId, user.id);
|
|
1302
3079
|
if (existing) {
|
|
@@ -1330,7 +3107,7 @@ async function getHidUserFromReader(readerId: string, id: unknown) {
|
|
|
1330
3107
|
|
|
1331
3108
|
async function updateHidUserOnReader(
|
|
1332
3109
|
readerId: string,
|
|
1333
|
-
user: { id?: number; name: string; registration: string }
|
|
3110
|
+
user: { id?: number; name: string; registration: string }
|
|
1334
3111
|
) {
|
|
1335
3112
|
const { id, ...values } = user;
|
|
1336
3113
|
await runObjectOperation(readerId, {
|
|
@@ -1345,10 +3122,7 @@ async function updateHidUserOnReader(
|
|
|
1345
3122
|
});
|
|
1346
3123
|
}
|
|
1347
3124
|
|
|
1348
|
-
async function deleteHidUserFromReader(
|
|
1349
|
-
readerId: string,
|
|
1350
|
-
user: HidUser,
|
|
1351
|
-
) {
|
|
3125
|
+
async function deleteHidUserFromReader(readerId: string, user: HidUser) {
|
|
1352
3126
|
const id = toHidNumericId(user.hidUserId);
|
|
1353
3127
|
if (!readerId || !id) return;
|
|
1354
3128
|
|
|
@@ -1436,7 +3210,9 @@ async function getHidUserRole(readerId: string, userId: number) {
|
|
|
1436
3210
|
return Array.isArray(roles) ? roles[0] : undefined;
|
|
1437
3211
|
}
|
|
1438
3212
|
|
|
1439
|
-
function toHidUserObject(
|
|
3213
|
+
function toHidUserObject(
|
|
3214
|
+
identity: Pick<HidUser, "hidUserId" | "name" | "registration">
|
|
3215
|
+
) {
|
|
1440
3216
|
return {
|
|
1441
3217
|
id: toHidNumericId(identity.hidUserId),
|
|
1442
3218
|
name:
|
|
@@ -1447,6 +3223,23 @@ function toHidUserObject(identity: Pick<HidUser, "hidUserId" | "name" | "registr
|
|
|
1447
3223
|
};
|
|
1448
3224
|
}
|
|
1449
3225
|
|
|
3226
|
+
function isVisitorUser(user: HidUser) {
|
|
3227
|
+
// The reader marks visitors with `user_type_id = 1`; a real user leaves the
|
|
3228
|
+
// field null. `metadata.hidUserTypeId` is what our API forwards it as.
|
|
3229
|
+
return Number(user.metadata?.hidUserTypeId ?? user.user_type_id) === 1;
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
function isUnsupportedUserTypeFilter(error: unknown) {
|
|
3233
|
+
const record = toRecord(error);
|
|
3234
|
+
const data = toRecord(record.data);
|
|
3235
|
+
const message = toText(data.message) || toText(record.message);
|
|
3236
|
+
const normalized = message.toLowerCase();
|
|
3237
|
+
return (
|
|
3238
|
+
normalized.includes("usertype") &&
|
|
3239
|
+
(normalized.includes("not allowed") || normalized.includes("unknown"))
|
|
3240
|
+
);
|
|
3241
|
+
}
|
|
3242
|
+
|
|
1450
3243
|
function toHidNumericId(value: unknown) {
|
|
1451
3244
|
const raw = String(value ?? "").trim();
|
|
1452
3245
|
if (!raw) return undefined;
|
|
@@ -1475,16 +3268,19 @@ async function loadVisibleUserCards(items: HidUser[]) {
|
|
|
1475
3268
|
const response = await getUserCards(selectedReaderId.value, hidUserId);
|
|
1476
3269
|
const responseData = toRecord(toRecord(response).data);
|
|
1477
3270
|
const cards = responseData.cards;
|
|
1478
|
-
return [
|
|
3271
|
+
return [
|
|
3272
|
+
String(hidUserId),
|
|
3273
|
+
(Array.isArray(cards) ? cards : []).map(toHidCard),
|
|
3274
|
+
] as const;
|
|
1479
3275
|
} catch (error: unknown) {
|
|
1480
3276
|
cardErrors[String(hidUserId)] = getHidErrorMessage(error);
|
|
1481
3277
|
return [String(hidUserId), []] as const;
|
|
1482
3278
|
}
|
|
1483
|
-
})
|
|
3279
|
+
})
|
|
1484
3280
|
);
|
|
1485
3281
|
|
|
1486
3282
|
physicalCardsByUserId.value = Object.fromEntries(
|
|
1487
|
-
entries.filter(([hidUserId]) => Boolean(hidUserId))
|
|
3283
|
+
entries.filter(([hidUserId]) => Boolean(hidUserId))
|
|
1488
3284
|
);
|
|
1489
3285
|
physicalCardErrorsByUserId.value = cardErrors;
|
|
1490
3286
|
}
|
|
@@ -1496,12 +3292,16 @@ function getUserPhysicalCards(user: HidUser) {
|
|
|
1496
3292
|
|
|
1497
3293
|
function getUserCardLoadError(user: HidUser) {
|
|
1498
3294
|
const hidUserId = toHidNumericId(user.hidUserId);
|
|
1499
|
-
return hidUserId
|
|
3295
|
+
return hidUserId
|
|
3296
|
+
? physicalCardErrorsByUserId.value[String(hidUserId)] || ""
|
|
3297
|
+
: "";
|
|
1500
3298
|
}
|
|
1501
3299
|
|
|
1502
3300
|
async function openCardManager(user: HidUser) {
|
|
1503
3301
|
cardUser.value = user;
|
|
1504
|
-
const reader = readers.value.find(
|
|
3302
|
+
const reader = readers.value.find(
|
|
3303
|
+
(item) => String(item._id) === String(user.reader || selectedReaderId.value)
|
|
3304
|
+
);
|
|
1505
3305
|
cardForm.cardType = reader?.cardReadingMode === "pacs" ? "pacs" : "csn";
|
|
1506
3306
|
cardForm.facilityCode = "";
|
|
1507
3307
|
cardForm.cardNumber = "";
|
|
@@ -1512,7 +3312,9 @@ async function openCardManager(user: HidUser) {
|
|
|
1512
3312
|
}
|
|
1513
3313
|
|
|
1514
3314
|
function getCardReaderAndUser() {
|
|
1515
|
-
const readerId = String(
|
|
3315
|
+
const readerId = String(
|
|
3316
|
+
cardUser.value?.reader || selectedReaderId.value || ""
|
|
3317
|
+
);
|
|
1516
3318
|
const hidUserId = toHidNumericId(cardUser.value?.hidUserId);
|
|
1517
3319
|
return { readerId, hidUserId };
|
|
1518
3320
|
}
|
|
@@ -1535,7 +3337,11 @@ async function loadPhysicalCards() {
|
|
|
1535
3337
|
}
|
|
1536
3338
|
}
|
|
1537
3339
|
|
|
1538
|
-
function parsePhysicalCardInteger(
|
|
3340
|
+
function parsePhysicalCardInteger(
|
|
3341
|
+
value: string,
|
|
3342
|
+
label: string,
|
|
3343
|
+
maximum: bigint
|
|
3344
|
+
) {
|
|
1539
3345
|
if (!/^\d+$/.test(value.trim())) {
|
|
1540
3346
|
showToast(`${label} must contain numbers only.`, "error");
|
|
1541
3347
|
return null;
|
|
@@ -1556,14 +3362,27 @@ async function assignPhysicalCard() {
|
|
|
1556
3362
|
| { cardType: "pacs"; facilityCode: string; cardNumber: string }
|
|
1557
3363
|
| { cardType: "csn"; cardValue: string };
|
|
1558
3364
|
if (cardForm.cardType === "pacs") {
|
|
1559
|
-
const facilityCode = parsePhysicalCardInteger(
|
|
1560
|
-
|
|
3365
|
+
const facilityCode = parsePhysicalCardInteger(
|
|
3366
|
+
cardForm.facilityCode,
|
|
3367
|
+
"Facility Code",
|
|
3368
|
+
4294967295n
|
|
3369
|
+
);
|
|
3370
|
+
const cardNumber = parsePhysicalCardInteger(
|
|
3371
|
+
cardForm.cardNumber,
|
|
3372
|
+
"Card Number",
|
|
3373
|
+
4294967295n
|
|
3374
|
+
);
|
|
1561
3375
|
if (facilityCode === null || cardNumber === null) return;
|
|
1562
3376
|
payload = { cardType: "pacs", facilityCode, cardNumber };
|
|
1563
3377
|
} else {
|
|
1564
|
-
const cardValue = parsePhysicalCardInteger(
|
|
3378
|
+
const cardValue = parsePhysicalCardInteger(
|
|
3379
|
+
cardForm.cardValue,
|
|
3380
|
+
"CSN",
|
|
3381
|
+
18446744073709551615n
|
|
3382
|
+
);
|
|
1565
3383
|
if (cardValue === null || BigInt(cardValue) <= 0n) {
|
|
1566
|
-
if (cardValue === "0")
|
|
3384
|
+
if (cardValue === "0")
|
|
3385
|
+
showToast("CSN must be greater than zero.", "error");
|
|
1567
3386
|
return;
|
|
1568
3387
|
}
|
|
1569
3388
|
payload = { cardType: "csn", cardValue };
|
|
@@ -1585,6 +3404,14 @@ async function assignPhysicalCard() {
|
|
|
1585
3404
|
}
|
|
1586
3405
|
}
|
|
1587
3406
|
|
|
3407
|
+
/**
|
|
3408
|
+
* Read a physical card by presenting it to the reader.
|
|
3409
|
+
*
|
|
3410
|
+
* The way most cards get assigned, because it is the only one that does not
|
|
3411
|
+
* require knowing the facility code and card number in advance - neither is
|
|
3412
|
+
* printed on the card, and an operator holding a blank fob has no way to look
|
|
3413
|
+
* them up. The reader listens for 30 seconds and reports what it read.
|
|
3414
|
+
*/
|
|
1588
3415
|
async function enrollPhysicalCard() {
|
|
1589
3416
|
const { readerId, hidUserId } = getCardReaderAndUser();
|
|
1590
3417
|
if (!readerId || !hidUserId) return;
|
|
@@ -1597,6 +3424,8 @@ async function enrollPhysicalCard() {
|
|
|
1597
3424
|
await loadUsers();
|
|
1598
3425
|
showToast("Physical HID card read and enrolled on the reader.");
|
|
1599
3426
|
} catch (error: unknown) {
|
|
3427
|
+
// Cancelling rejects the request above. That is the operator getting what
|
|
3428
|
+
// they asked for, so it must not also be reported as a failure.
|
|
1600
3429
|
if (!cardEnrollmentCancelled.value) {
|
|
1601
3430
|
showToast(getHidErrorMessage(error), "error");
|
|
1602
3431
|
}
|
|
@@ -1605,20 +3434,55 @@ async function enrollPhysicalCard() {
|
|
|
1605
3434
|
}
|
|
1606
3435
|
}
|
|
1607
3436
|
|
|
3437
|
+
/**
|
|
3438
|
+
* Stop the reader listening.
|
|
3439
|
+
*
|
|
3440
|
+
* Told to the DEVICE, not just the screen: abandoning the request on this side
|
|
3441
|
+
* would leave the reader waiting out its 30 seconds and enrolling whatever card
|
|
3442
|
+
* was presented in the meantime.
|
|
3443
|
+
*/
|
|
3444
|
+
/**
|
|
3445
|
+
* Stop the reader listening, and hand the dialog straight back.
|
|
3446
|
+
*
|
|
3447
|
+
* Told to the DEVICE, not just the screen: abandoning the request on this side
|
|
3448
|
+
* would leave the reader waiting out its remaining seconds and enrolling
|
|
3449
|
+
* whatever card was presented in the meantime.
|
|
3450
|
+
*
|
|
3451
|
+
* The part worth understanding is what the ENROL request does next. The server
|
|
3452
|
+
* cannot tell a cancelled read from a failed one - the reader simply reports no
|
|
3453
|
+
* card - so the enrol that is still in flight ends as a 400, seconds after the
|
|
3454
|
+
* cancel. Waiting for it was the bug: Cancel appeared to do nothing until that
|
|
3455
|
+
* rejection finally arrived.
|
|
3456
|
+
*
|
|
3457
|
+
* So the dialog is released as soon as the DEVICE confirms it has stopped, and
|
|
3458
|
+
* the doomed enrol is left to land on its own. `cardEnrollmentCancelled` is set
|
|
3459
|
+
* before anything is awaited, which is what keeps its 400 silent.
|
|
3460
|
+
*/
|
|
1608
3461
|
async function cancelPhysicalCardEnrollment() {
|
|
1609
3462
|
const { readerId } = getCardReaderAndUser();
|
|
1610
3463
|
if (!readerId) return;
|
|
3464
|
+
// Set first, awaited second: the enrol can reject at any point from here.
|
|
1611
3465
|
cardEnrollmentCancelled.value = true;
|
|
3466
|
+
cancellingCard.value = true;
|
|
1612
3467
|
try {
|
|
1613
3468
|
await cancelUserCardEnrollment(readerId);
|
|
3469
|
+
// Only now, because the enrollment lock is released by the call above.
|
|
3470
|
+
// Re-enabling Read at Reader any earlier invites a second attempt that
|
|
3471
|
+
// the reader refuses with "already running".
|
|
3472
|
+
enrollingCard.value = false;
|
|
1614
3473
|
showToast("Physical HID card reading cancelled.");
|
|
1615
3474
|
} catch (error: unknown) {
|
|
3475
|
+
// The cancel itself failing is real and worth saying: the reader may still
|
|
3476
|
+
// be listening, so the dialog stays in its reading state.
|
|
1616
3477
|
showToast(getHidErrorMessage(error), "error");
|
|
3478
|
+
} finally {
|
|
3479
|
+
cancellingCard.value = false;
|
|
1617
3480
|
}
|
|
1618
3481
|
}
|
|
1619
3482
|
|
|
1620
3483
|
async function removePhysicalCard(card: HidCard) {
|
|
1621
|
-
if (!window.confirm(`Remove physical HID card ${formatPhysicalCard(card)}?`))
|
|
3484
|
+
if (!window.confirm(`Remove physical HID card ${formatPhysicalCard(card)}?`))
|
|
3485
|
+
return;
|
|
1622
3486
|
const { readerId, hidUserId } = getCardReaderAndUser();
|
|
1623
3487
|
if (!readerId || !hidUserId || !card?.id) return;
|
|
1624
3488
|
|
|
@@ -1644,13 +3508,38 @@ function formatPhysicalCard(card: HidCard) {
|
|
|
1644
3508
|
}
|
|
1645
3509
|
|
|
1646
3510
|
function getPhysicalCardSubtitle(card: HidCard) {
|
|
1647
|
-
const source =
|
|
3511
|
+
const source =
|
|
3512
|
+
card?.source === "reader"
|
|
3513
|
+
? "Read at reader"
|
|
3514
|
+
: card?.source === "manual"
|
|
3515
|
+
? "Manual"
|
|
3516
|
+
: "On device";
|
|
1648
3517
|
return `${source} - Raw value ${card?.value || "N/A"}`;
|
|
1649
3518
|
}
|
|
1650
3519
|
|
|
1651
3520
|
function buildUnitLabel() {
|
|
1652
|
-
|
|
1653
|
-
|
|
3521
|
+
// `form.block/level/unit` hold ObjectIds, so the label has to resolve them
|
|
3522
|
+
// back to names through the occupancy tree. Joining the raw values would
|
|
3523
|
+
// stamp "665f.../665f.../665f..." onto the record and into the Unit column.
|
|
3524
|
+
const block = occupiedBlocks.value.find(
|
|
3525
|
+
(item) => String(item._id ?? "") === form.block
|
|
3526
|
+
);
|
|
3527
|
+
const level = (block?.levels ?? []).find(
|
|
3528
|
+
(item) => String(item._id ?? "") === form.level
|
|
3529
|
+
);
|
|
3530
|
+
const unit = (level?.units ?? []).find(
|
|
3531
|
+
(item) => String(item._id ?? "") === form.unit
|
|
3532
|
+
);
|
|
3533
|
+
const parts = [
|
|
3534
|
+
formatBlockTitle(block),
|
|
3535
|
+
String(level?.name ?? ""),
|
|
3536
|
+
String(unit?.name ?? ""),
|
|
3537
|
+
].filter(Boolean);
|
|
3538
|
+
// Editing a record enrolled before the cascade existed: keep its stored
|
|
3539
|
+
// label rather than blanking it.
|
|
3540
|
+
if (!parts.length)
|
|
3541
|
+
return String(selectedUser.value?.metadata?.unitLabel ?? "");
|
|
3542
|
+
return parts.join(" / ");
|
|
1654
3543
|
}
|
|
1655
3544
|
|
|
1656
3545
|
function getName(user?: HidUser | null) {
|
|
@@ -1737,27 +3626,28 @@ function normalizeHidUsers(response: unknown): HidUser[] {
|
|
|
1737
3626
|
const responseRecord = toRecord(response);
|
|
1738
3627
|
const responseData = toRecord(responseRecord.data);
|
|
1739
3628
|
const nestedData = toRecord(responseData.data);
|
|
1740
|
-
const items =
|
|
3629
|
+
const items =
|
|
3630
|
+
responseData.users ?? nestedData.users ?? responseRecord.users ?? [];
|
|
1741
3631
|
|
|
1742
3632
|
return Array.isArray(items)
|
|
1743
3633
|
? items.map((value) => {
|
|
1744
3634
|
const user = toRecord(value);
|
|
1745
3635
|
return {
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
name: toText(user.name),
|
|
1753
|
-
metadata: {
|
|
3636
|
+
_rowKey: `reader-${user.id}`,
|
|
3637
|
+
hidUserId: String(user.id || ""),
|
|
3638
|
+
rawHidUserId: toText(user.id),
|
|
3639
|
+
registration: toText(user.registration),
|
|
3640
|
+
userTypeId: toText(user.user_type_id),
|
|
3641
|
+
cardNo: toText(user.card_value || user.cardNo),
|
|
1754
3642
|
name: toText(user.name),
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
:
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
3643
|
+
metadata: {
|
|
3644
|
+
name: toText(user.name),
|
|
3645
|
+
imageTimestamp: user.image_timestamp
|
|
3646
|
+
? String(user.image_timestamp)
|
|
3647
|
+
: "",
|
|
3648
|
+
lastAccessAt: fromUnixSeconds(user.last_access),
|
|
3649
|
+
hidReaderSource: true,
|
|
3650
|
+
},
|
|
1761
3651
|
};
|
|
1762
3652
|
})
|
|
1763
3653
|
: [];
|
|
@@ -1765,7 +3655,7 @@ function normalizeHidUsers(response: unknown): HidUser[] {
|
|
|
1765
3655
|
|
|
1766
3656
|
function mergeReaderUsersWithIdentities(
|
|
1767
3657
|
readerUsers: HidUser[],
|
|
1768
|
-
identities: HidUser[]
|
|
3658
|
+
identities: HidUser[]
|
|
1769
3659
|
) {
|
|
1770
3660
|
const identityById = new Map<string, HidUser>();
|
|
1771
3661
|
identities.forEach((identity) => {
|
|
@@ -1808,7 +3698,13 @@ function getMappingStatus(user: HidUser) {
|
|
|
1808
3698
|
}
|
|
1809
3699
|
|
|
1810
3700
|
function hasMappedRecord(user: HidUser) {
|
|
1811
|
-
return Boolean(
|
|
3701
|
+
return Boolean(
|
|
3702
|
+
user?.person ||
|
|
3703
|
+
user?.user ||
|
|
3704
|
+
user?.member ||
|
|
3705
|
+
user?.serviceProvider ||
|
|
3706
|
+
user?.visitor
|
|
3707
|
+
);
|
|
1812
3708
|
}
|
|
1813
3709
|
|
|
1814
3710
|
function formatDate(value?: string) {
|
|
@@ -1840,18 +3736,26 @@ function getHidErrorMessage(error: unknown) {
|
|
|
1840
3736
|
const cause = toRecord(errorRecord.cause);
|
|
1841
3737
|
const causeResponse = toRecord(cause.response);
|
|
1842
3738
|
const data = toRecord(
|
|
1843
|
-
errorRecord.data ||
|
|
3739
|
+
errorRecord.data ||
|
|
3740
|
+
response._data ||
|
|
3741
|
+
response.data ||
|
|
3742
|
+
cause.data ||
|
|
3743
|
+
causeResponse._data
|
|
1844
3744
|
);
|
|
1845
3745
|
|
|
1846
3746
|
return toText(
|
|
1847
|
-
data.message ||
|
|
1848
|
-
|
|
3747
|
+
data.message ||
|
|
3748
|
+
data.error ||
|
|
3749
|
+
data.statusMessage ||
|
|
3750
|
+
errorRecord.statusMessage ||
|
|
3751
|
+
errorRecord.message,
|
|
3752
|
+
"Unable to save HID user."
|
|
1849
3753
|
);
|
|
1850
3754
|
}
|
|
1851
3755
|
|
|
1852
3756
|
function toRecord(value: unknown): Record<string, unknown> {
|
|
1853
3757
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
1854
|
-
? value as Record<string, unknown>
|
|
3758
|
+
? (value as Record<string, unknown>)
|
|
1855
3759
|
: {};
|
|
1856
3760
|
}
|
|
1857
3761
|
|
|
@@ -1897,10 +3801,24 @@ function toHidCard(value: unknown): HidCard {
|
|
|
1897
3801
|
return {
|
|
1898
3802
|
...card,
|
|
1899
3803
|
id: toText(card.id) || undefined,
|
|
1900
|
-
cardType:
|
|
1901
|
-
|
|
3804
|
+
cardType:
|
|
3805
|
+
card.cardType === "pacs" || card.cardType === "csn"
|
|
3806
|
+
? card.cardType
|
|
3807
|
+
: undefined,
|
|
3808
|
+
source:
|
|
3809
|
+
card.source === "reader" ||
|
|
3810
|
+
card.source === "manual" ||
|
|
3811
|
+
card.source === "device"
|
|
3812
|
+
? card.source
|
|
3813
|
+
: undefined,
|
|
1902
3814
|
};
|
|
1903
3815
|
}
|
|
3816
|
+
|
|
3817
|
+
/* Publish every change, including the seed above: a screen that had to fall
|
|
3818
|
+
back to the first reader should leave the others agreeing with it. */
|
|
3819
|
+
watch(selectedReaderId, (value) => {
|
|
3820
|
+
if (value) selectHidReader(props.site, value);
|
|
3821
|
+
});
|
|
1904
3822
|
</script>
|
|
1905
3823
|
|
|
1906
3824
|
<style scoped>
|
|
@@ -1937,12 +3855,6 @@ function toHidCard(value: unknown): HidCard {
|
|
|
1937
3855
|
max-width: 320px;
|
|
1938
3856
|
}
|
|
1939
3857
|
|
|
1940
|
-
.small-title {
|
|
1941
|
-
font-size: 14px;
|
|
1942
|
-
font-weight: 700;
|
|
1943
|
-
color: var(--text);
|
|
1944
|
-
}
|
|
1945
|
-
|
|
1946
3858
|
/* Was `#4f5a66` / `#e53935` - the same two greys on both themes. */
|
|
1947
3859
|
.field-label {
|
|
1948
3860
|
color: var(--text2);
|
|
@@ -1964,12 +3876,83 @@ function toHidCard(value: unknown): HidCard {
|
|
|
1964
3876
|
line-height: 1.45;
|
|
1965
3877
|
}
|
|
1966
3878
|
|
|
3879
|
+
/* Same line, saying the field is refused rather than explained. `--err` is the
|
|
3880
|
+
token the required-field asterisk already uses. */
|
|
3881
|
+
.field-hint--error {
|
|
3882
|
+
color: var(--err);
|
|
3883
|
+
}
|
|
3884
|
+
|
|
3885
|
+
/* The bulk button and the sentence that says what it does. Stacked rather than
|
|
3886
|
+
side by side: the sentence is the part that stops somebody expecting this to
|
|
3887
|
+
enrol faces, so it must not be squeezed into a margin at narrow widths. */
|
|
3888
|
+
.hid-bulk-cta {
|
|
3889
|
+
display: flex;
|
|
3890
|
+
flex-direction: column;
|
|
3891
|
+
align-items: flex-start;
|
|
3892
|
+
gap: 4px;
|
|
3893
|
+
}
|
|
3894
|
+
|
|
3895
|
+
.hid-bulk-cta .field-hint {
|
|
3896
|
+
margin: 0;
|
|
3897
|
+
}
|
|
3898
|
+
|
|
3899
|
+
/* Checkbox column only as wide as the control, so the three columns that carry
|
|
3900
|
+
meaning keep the width. */
|
|
3901
|
+
.hid-bulk-table__check {
|
|
3902
|
+
width: 44px;
|
|
3903
|
+
padding-right: 0 !important;
|
|
3904
|
+
}
|
|
3905
|
+
|
|
3906
|
+
.hid-bulk-table :deep(th) {
|
|
3907
|
+
font-size: 12px;
|
|
3908
|
+
font-weight: 700;
|
|
3909
|
+
color: var(--muted);
|
|
3910
|
+
white-space: nowrap;
|
|
3911
|
+
}
|
|
3912
|
+
|
|
3913
|
+
.hid-bulk-table :deep(td) {
|
|
3914
|
+
font-size: 13px;
|
|
3915
|
+
}
|
|
3916
|
+
|
|
3917
|
+
/* Why this row cannot be unticked, on the row itself. A disabled checkbox with
|
|
3918
|
+
no reason beside it reads as a broken control. */
|
|
3919
|
+
.hid-bulk-granted {
|
|
3920
|
+
display: block;
|
|
3921
|
+
color: var(--muted);
|
|
3922
|
+
font-size: 11px;
|
|
3923
|
+
font-weight: 600;
|
|
3924
|
+
}
|
|
3925
|
+
|
|
1967
3926
|
.photo-wrap {
|
|
1968
3927
|
display: grid;
|
|
1969
3928
|
place-items: center;
|
|
1970
3929
|
margin-bottom: 14px;
|
|
1971
3930
|
}
|
|
1972
3931
|
|
|
3932
|
+
.photo-facial {
|
|
3933
|
+
margin: 10px 0 0;
|
|
3934
|
+
text-align: center;
|
|
3935
|
+
}
|
|
3936
|
+
|
|
3937
|
+
/* The same small muted word `.field-label` uses, so the caption reads as a
|
|
3938
|
+
field label rather than as a second heading. */
|
|
3939
|
+
.photo-facial__label {
|
|
3940
|
+
display: block;
|
|
3941
|
+
color: var(--muted);
|
|
3942
|
+
font-size: 12px;
|
|
3943
|
+
font-weight: 600;
|
|
3944
|
+
}
|
|
3945
|
+
|
|
3946
|
+
.photo-facial__value {
|
|
3947
|
+
display: block;
|
|
3948
|
+
margin-top: 2px;
|
|
3949
|
+
font-size: 13.5px;
|
|
3950
|
+
font-weight: 500;
|
|
3951
|
+
/* The reader's reference is a long unbroken number; let it wrap rather than
|
|
3952
|
+
widen the dialog. */
|
|
3953
|
+
word-break: break-all;
|
|
3954
|
+
}
|
|
3955
|
+
|
|
1973
3956
|
.photo-button {
|
|
1974
3957
|
width: 120px;
|
|
1975
3958
|
height: 120px;
|
|
@@ -2003,7 +3986,6 @@ function toHidCard(value: unknown): HidCard {
|
|
|
2003
3986
|
box-shadow: var(--shadow-menu);
|
|
2004
3987
|
}
|
|
2005
3988
|
|
|
2006
|
-
|
|
2007
3989
|
.admin-rule-indicator {
|
|
2008
3990
|
display: inline-flex;
|
|
2009
3991
|
vertical-align: middle;
|
|
@@ -2143,25 +4125,6 @@ function toHidCard(value: unknown): HidCard {
|
|
|
2143
4125
|
color: var(--muted);
|
|
2144
4126
|
}
|
|
2145
4127
|
|
|
2146
|
-
/* The split footer bar. The handoff does not draw one, so the SHAPE is the
|
|
2147
|
-
screen's own; only the colours change - `#062d42` was a fixed navy that sat
|
|
2148
|
-
on the dark card unchanged. Same treatment as panel 2. */
|
|
2149
|
-
.action-cancel,
|
|
2150
|
-
.action-submit {
|
|
2151
|
-
flex: 1;
|
|
2152
|
-
border-radius: 0;
|
|
2153
|
-
}
|
|
2154
|
-
|
|
2155
|
-
.action-cancel {
|
|
2156
|
-
background: var(--card);
|
|
2157
|
-
color: var(--text2);
|
|
2158
|
-
}
|
|
2159
|
-
|
|
2160
|
-
.action-submit {
|
|
2161
|
-
background: var(--accent-strong);
|
|
2162
|
-
color: var(--on-accent-strong);
|
|
2163
|
-
}
|
|
2164
|
-
|
|
2165
4128
|
@media (max-width: 960px) {
|
|
2166
4129
|
.hid-user-filters :deep(.app-field),
|
|
2167
4130
|
.hid-user-filters :deep(.app-select) {
|