@7365admin1/layer-common 4.90.2-staging.490 → 4.90.2
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 +0 -57
- package/assets/css/screens.css +4 -8
- package/components/AccessCardDetailsDialog.vue +6 -59
- package/components/AccessCardPreviewDialog.vue +53 -371
- package/components/AppSelect.vue +4 -143
- package/components/BuildingUnitFormEdit.vue +0 -6
- package/components/Dialog/UpdateMoreAction.vue +1 -11
- package/components/DocumentForm.vue +112 -407
- package/components/DocumentManagement.vue +12 -213
- package/components/Facility/BookingSetup.vue +0 -63
- package/components/HidAccessLogDashboard.vue +17 -74
- package/components/HidIntercomManagement.vue +14 -183
- package/components/HidQrCodeConfiguration.vue +195 -17
- package/components/HidReaderManagement.vue +0 -398
- package/components/HidReaderUserRoster.vue +3 -44
- package/components/HidUserEnrollment.vue +292 -2255
- package/components/InvitationClientForm.vue +1 -19
- package/components/InvitationForm.vue +4 -31
- package/components/InvitationMain.vue +22 -124
- package/components/MemberInformation.vue +4 -4
- package/components/PassInformation.vue +2 -2
- package/components/SiteSettings.vue +0 -48
- package/components/TableMain.vue +7 -22
- package/components/VehicleManagement.vue +5 -80
- package/components/VisitorForm.vue +19 -65
- package/components/VisitorManagement.vue +5 -267
- package/composables/useAccessManagement.ts +1 -17
- package/composables/useDocument.ts +0 -7
- package/composables/useFacility.ts +0 -11
- package/composables/useHidAmico.ts +0 -165
- package/composables/useHidNavigation.ts +1 -25
- package/composables/useMember.ts +0 -12
- package/composables/useNFCPatrolReportFilters.ts +3 -21
- package/composables/useNFCPatrolRoute.ts +1 -8
- package/composables/usePeople.ts +0 -19
- package/composables/useServiceProvider.ts +2 -45
- package/composables/useSettingsPermission.ts +0 -14
- package/composables/useUser.ts +2 -6
- package/composables/useVehicle.ts +1 -40
- package/composables/useVerification.ts +0 -3
- package/middleware/01.auth.ts +7 -3
- 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-readers/index.vue +1 -1
- package/types/document.d.ts +0 -14
- package/types/facility.d.ts +0 -4
- package/types/member.d.ts +0 -4
- package/types/people.d.ts +1 -28
- package/types/service-provider.d.ts +0 -5
- package/types/site.d.ts +1 -7
- package/components/BuildingUnitManagement.vue +0 -298
- package/components/HidAccessPermissions.vue +0 -424
- package/components/HidProfileQrCode.vue +0 -332
- package/components/VehicleQrStickerDialog.vue +0 -249
- package/composables/useHidReaderSelection.ts +0 -50
- package/composables/useSiteCategory.ts +0 -45
- package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +0 -23
- package/utils/hid-enrolment-subject.ts +0 -344
- package/utils/hid-permission-assignments.ts +0 -198
- package/utils/hid-reader-selection.ts +0 -52
- package/utils/invite-group.ts +0 -61
- package/utils/invite-sites.ts +0 -25
- package/utils/occupancy-role.ts +0 -109
- package/utils/service-type.ts +0 -30
- package/utils/vehicle-qr-sticker.ts +0 -184
|
@@ -25,11 +25,7 @@
|
|
|
25
25
|
the old table card, so it stays above the shared one. -->
|
|
26
26
|
<v-alert
|
|
27
27
|
v-if="selectedReader"
|
|
28
|
-
:type="
|
|
29
|
-
selectedReader.portalId && selectedReader.portalName
|
|
30
|
-
? 'info'
|
|
31
|
-
: 'warning'
|
|
32
|
-
"
|
|
28
|
+
:type="selectedReader.portalId && selectedReader.portalName ? 'info' : 'warning'"
|
|
33
29
|
variant="tonal"
|
|
34
30
|
density="compact"
|
|
35
31
|
class="mb-4"
|
|
@@ -54,9 +50,7 @@
|
|
|
54
50
|
@update:page="goToPage"
|
|
55
51
|
>
|
|
56
52
|
<template v-if="!props.cardManagement" #actions>
|
|
57
|
-
<AppButton icon="mdi-account-plus" @click="openEnroll"
|
|
58
|
-
>Enroll</AppButton
|
|
59
|
-
>
|
|
53
|
+
<AppButton icon="mdi-account-plus" @click="openEnroll">Enroll</AppButton>
|
|
60
54
|
</template>
|
|
61
55
|
|
|
62
56
|
<!-- The design puts filters on their own full-width row under the
|
|
@@ -73,8 +67,8 @@
|
|
|
73
67
|
<AppField
|
|
74
68
|
v-model="search"
|
|
75
69
|
search
|
|
76
|
-
placeholder="Search
|
|
77
|
-
@keyup.enter="
|
|
70
|
+
placeholder="Search"
|
|
71
|
+
@keyup.enter="reloadUsersFromFirstPage"
|
|
78
72
|
/>
|
|
79
73
|
<AppSelect
|
|
80
74
|
v-if="!props.cardManagement"
|
|
@@ -99,15 +93,7 @@
|
|
|
99
93
|
</template>
|
|
100
94
|
|
|
101
95
|
<template #[`item.hidUserId`]="{ item }">
|
|
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>
|
|
96
|
+
<span class="app-cell--num">{{ formatHidUid(item.hidUserId) || "N/A" }}</span>
|
|
111
97
|
</template>
|
|
112
98
|
|
|
113
99
|
<!-- The shared chip. `utils/status.ts` now carries Mapped/Unmapped with
|
|
@@ -127,10 +113,7 @@
|
|
|
127
113
|
{{ formatPhysicalCard(card) }}
|
|
128
114
|
</span>
|
|
129
115
|
</div>
|
|
130
|
-
<span
|
|
131
|
-
v-else-if="getUserCardLoadError(item)"
|
|
132
|
-
class="hid-card-load-error"
|
|
133
|
-
>
|
|
116
|
+
<span v-else-if="getUserCardLoadError(item)" class="hid-card-load-error">
|
|
134
117
|
Unable to load
|
|
135
118
|
<v-tooltip activator="parent" location="top">
|
|
136
119
|
{{ getUserCardLoadError(item) }}
|
|
@@ -150,20 +133,13 @@
|
|
|
150
133
|
</AppButton>
|
|
151
134
|
<v-menu v-else>
|
|
152
135
|
<template #activator="{ props: menuProps }">
|
|
153
|
-
<AppButton
|
|
154
|
-
v-bind="menuProps"
|
|
155
|
-
variant="row"
|
|
156
|
-
icon="mdi-dots-vertical"
|
|
157
|
-
/>
|
|
136
|
+
<AppButton v-bind="menuProps" variant="row" icon="mdi-dots-vertical" />
|
|
158
137
|
</template>
|
|
159
138
|
|
|
160
139
|
<v-list density="compact" min-width="150">
|
|
161
140
|
<v-list-item title="View" @click="openView(item)" />
|
|
162
141
|
<v-list-item title="Edit" @click="openEdit(item)" />
|
|
163
|
-
<v-list-item
|
|
164
|
-
title="Manage HID Cards"
|
|
165
|
-
@click="openCardManager(item)"
|
|
166
|
-
/>
|
|
142
|
+
<v-list-item title="Manage HID Cards" @click="openCardManager(item)" />
|
|
167
143
|
<v-list-item
|
|
168
144
|
title="Delete"
|
|
169
145
|
class="text-error"
|
|
@@ -179,31 +155,23 @@
|
|
|
179
155
|
<template #no-data>
|
|
180
156
|
<div class="table-card__empty">
|
|
181
157
|
<v-icon
|
|
182
|
-
:icon="
|
|
183
|
-
props.cardManagement
|
|
184
|
-
? 'mdi-card-account-details-outline'
|
|
185
|
-
: 'mdi-account-off-outline'
|
|
186
|
-
"
|
|
158
|
+
:icon="props.cardManagement ? 'mdi-card-account-details-outline' : 'mdi-account-off-outline'"
|
|
187
159
|
size="32"
|
|
188
160
|
/>
|
|
189
161
|
<span>
|
|
190
|
-
{{
|
|
191
|
-
props.cardManagement
|
|
192
|
-
? "No HID users available for card assignment."
|
|
193
|
-
: "No HID users enrolled yet."
|
|
194
|
-
}}
|
|
162
|
+
{{ props.cardManagement ? "No HID users available for card assignment." : "No HID users enrolled yet." }}
|
|
195
163
|
</span>
|
|
196
164
|
</div>
|
|
197
165
|
</template>
|
|
198
166
|
</TableMain>
|
|
199
167
|
|
|
200
168
|
<v-dialog v-model="formDialog" max-width="420" persistent>
|
|
201
|
-
<v-card
|
|
202
|
-
<v-card-title>
|
|
169
|
+
<v-card rounded="lg">
|
|
170
|
+
<v-card-title class="small-title">
|
|
203
171
|
{{ selectedUser ? "Edit User Information" : "Enroll" }}
|
|
204
172
|
</v-card-title>
|
|
205
173
|
|
|
206
|
-
<v-card-text
|
|
174
|
+
<v-card-text>
|
|
207
175
|
<div class="photo-wrap">
|
|
208
176
|
<v-menu>
|
|
209
177
|
<template #activator="{ props: menuProps }">
|
|
@@ -245,15 +213,6 @@
|
|
|
245
213
|
/>
|
|
246
214
|
</v-list>
|
|
247
215
|
</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
|
-
|
|
257
216
|
<input
|
|
258
217
|
ref="photoInput"
|
|
259
218
|
type="file"
|
|
@@ -263,252 +222,45 @@
|
|
|
263
222
|
/>
|
|
264
223
|
</div>
|
|
265
224
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
<div class="field-label">Enrolling <span>*</span></div>
|
|
225
|
+
<div class="field-label">Name <span>*</span></div>
|
|
226
|
+
<AppField
|
|
227
|
+
v-model="form.name"
|
|
228
|
+
aria-label="Name"
|
|
229
|
+
placeholder="Enter name"
|
|
230
|
+
klass="mb-3"
|
|
231
|
+
/>
|
|
232
|
+
|
|
233
|
+
<div class="field-label">Link To <span>*</span></div>
|
|
276
234
|
<AppSelect
|
|
277
|
-
v-if="!selectedUser"
|
|
278
235
|
v-model="form.subjectCategory"
|
|
279
236
|
:items="subjectCategoryOptions"
|
|
237
|
+
placeholder="Select person type"
|
|
280
238
|
klass="mb-3"
|
|
281
239
|
@update:model-value="onSubjectCategoryChanged"
|
|
282
240
|
/>
|
|
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>
|
|
318
|
-
|
|
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
241
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
|
242
|
+
<div class="field-label">Resident / Staff / Provider <span>*</span></div>
|
|
243
|
+
<AppSelect
|
|
244
|
+
v-model="form.subjectId"
|
|
245
|
+
:items="subjectOptions"
|
|
246
|
+
:loading="loadingSubjects"
|
|
247
|
+
placeholder="Select an existing site record"
|
|
491
248
|
klass="mb-3"
|
|
492
249
|
/>
|
|
493
250
|
|
|
494
251
|
<div class="field-label">ID</div>
|
|
495
|
-
<AppField
|
|
496
|
-
v-model="form.hidUserId"
|
|
497
|
-
aria-label="ID"
|
|
498
|
-
readonly
|
|
499
|
-
klass="mb-3"
|
|
500
|
-
/>
|
|
252
|
+
<AppField v-model="form.hidUserId" aria-label="ID" readonly klass="mb-3" />
|
|
501
253
|
|
|
502
254
|
<div class="field-label">Registration No. <span>*</span></div>
|
|
503
255
|
<AppField
|
|
504
256
|
v-model="form.registration"
|
|
505
257
|
aria-label="Registration No."
|
|
506
258
|
placeholder="Enter registration no."
|
|
507
|
-
readonly
|
|
259
|
+
:readonly="!selectedUser"
|
|
508
260
|
klass="mb-3"
|
|
509
261
|
/>
|
|
510
262
|
|
|
511
|
-
<div class="field-label">PIN (optional)</div>
|
|
263
|
+
<div class="field-label">Access PIN (optional)</div>
|
|
512
264
|
<!--
|
|
513
265
|
The handoff draws no password field anywhere (the sign-in screens
|
|
514
266
|
are the owner's own), so the reveal toggle is this product's, not
|
|
@@ -518,8 +270,8 @@
|
|
|
518
270
|
-->
|
|
519
271
|
<AppField
|
|
520
272
|
v-model="form.accessPin"
|
|
521
|
-
aria-label="PIN"
|
|
522
|
-
placeholder="Enter
|
|
273
|
+
aria-label="Access PIN"
|
|
274
|
+
placeholder="Enter numeric access PIN"
|
|
523
275
|
inputmode="numeric"
|
|
524
276
|
autocomplete="new-password"
|
|
525
277
|
:type="showPin ? 'text' : 'password'"
|
|
@@ -528,7 +280,7 @@
|
|
|
528
280
|
<button
|
|
529
281
|
type="button"
|
|
530
282
|
class="app-field__trailing"
|
|
531
|
-
:aria-label="showPin ? 'Hide PIN' : 'Show PIN'"
|
|
283
|
+
:aria-label="showPin ? 'Hide access PIN' : 'Show access PIN'"
|
|
532
284
|
@click="showPin = !showPin"
|
|
533
285
|
>
|
|
534
286
|
<v-icon :icon="showPin ? 'mdi-eye-off' : 'mdi-eye'" size="18" />
|
|
@@ -538,82 +290,33 @@
|
|
|
538
290
|
<!-- `persistent-hint` was Vuetify's; the design's settings module
|
|
539
291
|
draws an explanatory line as 12-13px/600 in `--muted`. -->
|
|
540
292
|
<p class="field-hint">
|
|
541
|
-
{{
|
|
542
|
-
selectedUser && pinEnrolled
|
|
543
|
-
? "Leave blank to keep the current PIN."
|
|
544
|
-
: "PIN works as an alternative to face or QR."
|
|
545
|
-
}}
|
|
293
|
+
{{ selectedUser && pinEnrolled ? "Leave blank to keep the current PIN." : "PIN works as an alternative to face or QR." }}
|
|
546
294
|
</p>
|
|
547
295
|
<v-checkbox
|
|
548
296
|
v-if="selectedUser && pinEnrolled"
|
|
549
297
|
v-model="removePinRequested"
|
|
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"
|
|
298
|
+
label="Remove current access PIN"
|
|
595
299
|
density="compact"
|
|
596
300
|
hide-details
|
|
597
301
|
/>
|
|
598
302
|
</v-card-text>
|
|
599
303
|
|
|
600
|
-
<!-- The
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
<v-card-actions class="screen-modal__footer">
|
|
304
|
+
<!-- The split footer bar, as panel 2 keeps it: `v-btn` because it needs
|
|
305
|
+
`:loading` and a full-bleed shape the handoff does not draw. Only
|
|
306
|
+
the colours change - they were fixed hexes on both themes. -->
|
|
307
|
+
<v-card-actions class="pa-0">
|
|
605
308
|
<v-btn
|
|
606
|
-
class="text-none
|
|
309
|
+
class="text-none action-cancel"
|
|
607
310
|
variant="flat"
|
|
311
|
+
height="44"
|
|
608
312
|
@click="formDialog = false"
|
|
313
|
+
>Cancel</v-btn
|
|
609
314
|
>
|
|
610
|
-
Cancel
|
|
611
|
-
</v-btn>
|
|
612
315
|
<v-btn
|
|
613
|
-
class="text-none
|
|
316
|
+
class="text-none action-submit"
|
|
614
317
|
variant="flat"
|
|
318
|
+
height="44"
|
|
615
319
|
:loading="saving"
|
|
616
|
-
:disabled="Boolean(residentConflict) || checkingResident"
|
|
617
320
|
@click="saveUser"
|
|
618
321
|
>
|
|
619
322
|
Save
|
|
@@ -622,121 +325,12 @@
|
|
|
622
325
|
</v-card>
|
|
623
326
|
</v-dialog>
|
|
624
327
|
|
|
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
|
-
|
|
734
328
|
<HidCameraCaptureDialog v-model="cameraDialog" @capture="onCameraCapture" />
|
|
735
329
|
|
|
736
330
|
<v-dialog v-model="viewDialog" max-width="430">
|
|
737
|
-
<v-card
|
|
738
|
-
<v-card-title>User Information</v-card-title>
|
|
739
|
-
<v-card-text
|
|
331
|
+
<v-card rounded="lg">
|
|
332
|
+
<v-card-title class="small-title">User Information</v-card-title>
|
|
333
|
+
<v-card-text>
|
|
740
334
|
<div class="detail-grid">
|
|
741
335
|
<span>Name</span><strong>{{ getName(selectedUser) }}</strong>
|
|
742
336
|
<span>User ID</span
|
|
@@ -753,28 +347,21 @@
|
|
|
753
347
|
><strong>{{
|
|
754
348
|
selectedUser ? getMappingStatus(selectedUser) : "N/A"
|
|
755
349
|
}}</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>
|
|
350
|
+
<span>Access PIN</span
|
|
351
|
+
><strong>{{ selectedUser?.metadata?.pinEnrolled ? "Configured" : "N/A" }}</strong>
|
|
764
352
|
</div>
|
|
765
353
|
</v-card-text>
|
|
766
|
-
<v-card-actions
|
|
767
|
-
<
|
|
768
|
-
|
|
769
|
-
>
|
|
354
|
+
<v-card-actions>
|
|
355
|
+
<v-spacer />
|
|
356
|
+
<AppButton variant="ghost" @click="viewDialog = false">Close</AppButton>
|
|
770
357
|
</v-card-actions>
|
|
771
358
|
</v-card>
|
|
772
359
|
</v-dialog>
|
|
773
360
|
|
|
774
361
|
<v-dialog v-model="cardDialog" max-width="560" persistent>
|
|
775
|
-
<v-card
|
|
776
|
-
<v-card-title>Physical HID Cards</v-card-title>
|
|
777
|
-
<v-card-text
|
|
362
|
+
<v-card rounded="lg">
|
|
363
|
+
<v-card-title class="small-title">Physical HID Cards</v-card-title>
|
|
364
|
+
<v-card-text>
|
|
778
365
|
<p class="hid-card-user">
|
|
779
366
|
{{ getName(cardUser) }}
|
|
780
367
|
<span>{{ cardUser ? formatHidUid(cardUser.hidUserId) : "" }}</span>
|
|
@@ -782,11 +369,7 @@
|
|
|
782
369
|
|
|
783
370
|
<v-progress-linear v-if="loadingCards" indeterminate class="mb-3" />
|
|
784
371
|
<div v-else-if="physicalCards.length" class="hid-card-list mb-4">
|
|
785
|
-
<div
|
|
786
|
-
v-for="card in physicalCards"
|
|
787
|
-
:key="card.id"
|
|
788
|
-
class="hid-card-row"
|
|
789
|
-
>
|
|
372
|
+
<div v-for="card in physicalCards" :key="card.id" class="hid-card-row">
|
|
790
373
|
<v-icon icon="mdi-card-account-details-outline" size="22" />
|
|
791
374
|
<div class="hid-card-row__content">
|
|
792
375
|
<strong>{{ formatPhysicalCard(card) }}</strong>
|
|
@@ -800,13 +383,7 @@
|
|
|
800
383
|
/>
|
|
801
384
|
</div>
|
|
802
385
|
</div>
|
|
803
|
-
<v-alert
|
|
804
|
-
v-else
|
|
805
|
-
type="info"
|
|
806
|
-
variant="tonal"
|
|
807
|
-
density="compact"
|
|
808
|
-
class="mb-4"
|
|
809
|
-
>
|
|
386
|
+
<v-alert v-else type="info" variant="tonal" density="compact" class="mb-4">
|
|
810
387
|
No physical HID card is assigned to this user.
|
|
811
388
|
</v-alert>
|
|
812
389
|
|
|
@@ -820,8 +397,7 @@
|
|
|
820
397
|
{{ cardProfileLabel }}
|
|
821
398
|
</v-alert>
|
|
822
399
|
<p class="field-hint hid-card-type-hint">
|
|
823
|
-
PACS uses Facility Code + Card Number. CSN uses the raw serial
|
|
824
|
-
number read from the physical card.
|
|
400
|
+
PACS uses Facility Code + Card Number. CSN uses the raw serial number read from the physical card.
|
|
825
401
|
</p>
|
|
826
402
|
<template v-if="hybridCardProfile">
|
|
827
403
|
<div class="field-label mt-3">Presented Card Type</div>
|
|
@@ -832,13 +408,11 @@
|
|
|
832
408
|
class="mb-3"
|
|
833
409
|
/>
|
|
834
410
|
<p class="field-hint hid-card-type-hint">
|
|
835
|
-
Choose MIFARE CSN for a MIFARE card. Choose PACS for any other
|
|
836
|
-
card in this reader mode.
|
|
411
|
+
Choose MIFARE CSN for a MIFARE card. Choose PACS for any other card in this reader mode.
|
|
837
412
|
</p>
|
|
838
413
|
</template>
|
|
839
414
|
<p class="field-hint hid-card-access-hint">
|
|
840
|
-
The card uses this HID user's existing portal and access rules;
|
|
841
|
-
assigning a card does not grant new access.
|
|
415
|
+
The card uses this HID user's existing portal and access rules; assigning a card does not grant new access.
|
|
842
416
|
</p>
|
|
843
417
|
|
|
844
418
|
<div v-if="cardForm.cardType === 'pacs'" class="hid-card-fields">
|
|
@@ -875,15 +449,9 @@
|
|
|
875
449
|
density="compact"
|
|
876
450
|
class="mt-4"
|
|
877
451
|
>
|
|
878
|
-
Present the physical card to the selected Amico reader. The request
|
|
879
|
-
will time out after 30 seconds.
|
|
452
|
+
Present the physical card to the selected Amico reader. The request will time out after 30 seconds.
|
|
880
453
|
</v-alert>
|
|
881
454
|
|
|
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. -->
|
|
887
455
|
<div class="hid-card-actions mt-4">
|
|
888
456
|
<AppButton
|
|
889
457
|
variant="secondary"
|
|
@@ -902,21 +470,15 @@
|
|
|
902
470
|
<AppButton
|
|
903
471
|
v-if="enrollingCard"
|
|
904
472
|
variant="ghost"
|
|
905
|
-
:disabled="cancellingCard"
|
|
906
473
|
@click="cancelPhysicalCardEnrollment"
|
|
907
474
|
>
|
|
908
|
-
|
|
475
|
+
Cancel Reading
|
|
909
476
|
</AppButton>
|
|
910
477
|
</div>
|
|
911
478
|
</v-card-text>
|
|
912
|
-
<v-card-actions
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
<AppButton
|
|
916
|
-
variant="ghost"
|
|
917
|
-
:disabled="enrollingCard"
|
|
918
|
-
@click="cardDialog = false"
|
|
919
|
-
>
|
|
479
|
+
<v-card-actions>
|
|
480
|
+
<v-spacer />
|
|
481
|
+
<AppButton variant="ghost" :disabled="enrollingCard" @click="cardDialog = false">
|
|
920
482
|
Close
|
|
921
483
|
</AppButton>
|
|
922
484
|
</v-card-actions>
|
|
@@ -924,31 +486,31 @@
|
|
|
924
486
|
</v-dialog>
|
|
925
487
|
|
|
926
488
|
<v-dialog v-model="deleteDialog" max-width="360" persistent>
|
|
927
|
-
<v-card
|
|
928
|
-
<v-card-title>Delete</v-card-title>
|
|
929
|
-
<v-card-text class="
|
|
489
|
+
<v-card rounded="lg">
|
|
490
|
+
<v-card-title class="small-title">Delete</v-card-title>
|
|
491
|
+
<v-card-text class="text-body-2">
|
|
930
492
|
Are you sure you want to permanently delete this HID user?
|
|
931
493
|
<div class="text-caption text-medium-emphasis mt-2">
|
|
932
494
|
This action will remove the user from the HID reader and mark the
|
|
933
495
|
identity as deleted.
|
|
934
496
|
</div>
|
|
935
497
|
</v-card-text>
|
|
936
|
-
<v-card-actions class="
|
|
498
|
+
<v-card-actions class="pa-0">
|
|
937
499
|
<v-btn
|
|
938
|
-
class="text-none
|
|
500
|
+
class="text-none action-cancel"
|
|
939
501
|
variant="flat"
|
|
502
|
+
height="44"
|
|
940
503
|
@click="deleteDialog = false"
|
|
504
|
+
>Cancel</v-btn
|
|
941
505
|
>
|
|
942
|
-
Cancel
|
|
943
|
-
</v-btn>
|
|
944
506
|
<v-btn
|
|
945
|
-
class="text-none
|
|
507
|
+
class="text-none action-submit"
|
|
946
508
|
variant="flat"
|
|
509
|
+
height="44"
|
|
947
510
|
:loading="deleting"
|
|
948
511
|
@click="deleteUser"
|
|
512
|
+
>Delete</v-btn
|
|
949
513
|
>
|
|
950
|
-
Delete
|
|
951
|
-
</v-btn>
|
|
952
514
|
</v-card-actions>
|
|
953
515
|
</v-card>
|
|
954
516
|
</v-dialog>
|
|
@@ -960,55 +522,11 @@
|
|
|
960
522
|
</template>
|
|
961
523
|
|
|
962
524
|
<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
|
-
|
|
1000
525
|
const props = defineProps({
|
|
1001
526
|
site: {
|
|
1002
527
|
type: String,
|
|
1003
528
|
required: true,
|
|
1004
529
|
},
|
|
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
|
-
},
|
|
1012
530
|
cardManagement: {
|
|
1013
531
|
type: Boolean,
|
|
1014
532
|
default: false,
|
|
@@ -1029,21 +547,12 @@ const {
|
|
|
1029
547
|
getUserPinStatus,
|
|
1030
548
|
setUserPin,
|
|
1031
549
|
deleteUserPin,
|
|
1032
|
-
getUserPasswordStatus,
|
|
1033
|
-
setUserPassword,
|
|
1034
|
-
deleteUserPassword,
|
|
1035
550
|
runObjectOperation,
|
|
1036
551
|
createIdentity,
|
|
1037
552
|
updateIdentity,
|
|
1038
553
|
deleteIdentity,
|
|
1039
|
-
|
|
1040
|
-
updateSitePermissions,
|
|
554
|
+
getPermissionCandidates,
|
|
1041
555
|
} = 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();
|
|
1047
556
|
|
|
1048
557
|
type HidMetadata = Record<string, unknown> & {
|
|
1049
558
|
name?: string;
|
|
@@ -1057,7 +566,6 @@ type HidMetadata = Record<string, unknown> & {
|
|
|
1057
566
|
lastAccessAt?: string;
|
|
1058
567
|
facialScores?: Record<string, unknown>;
|
|
1059
568
|
pinEnrolled?: boolean;
|
|
1060
|
-
passwordSet?: boolean;
|
|
1061
569
|
hidPhysicalCards?: HidCard[];
|
|
1062
570
|
};
|
|
1063
571
|
|
|
@@ -1108,7 +616,6 @@ type HidIdentityPayload = Record<string, unknown> & {
|
|
|
1108
616
|
const readers = ref<HidReader[]>([]);
|
|
1109
617
|
const users = ref<HidUser[]>([]);
|
|
1110
618
|
const administratorUserIds = ref<Set<number>>(new Set());
|
|
1111
|
-
const { selectHidReader, resolveForSite } = useHidReaderSelection();
|
|
1112
619
|
const selectedReaderId = ref("");
|
|
1113
620
|
const search = ref("");
|
|
1114
621
|
const status = ref("");
|
|
@@ -1124,913 +631,71 @@ const formDialog = ref(false);
|
|
|
1124
631
|
const viewDialog = ref(false);
|
|
1125
632
|
const deleteDialog = ref(false);
|
|
1126
633
|
const cardDialog = ref(false);
|
|
1127
|
-
const selectedUser = ref<HidUser | null>(null);
|
|
1128
|
-
const cardUser = ref<HidUser | null>(null);
|
|
1129
|
-
const physicalCards = ref<HidCard[]>([]);
|
|
1130
|
-
const physicalCardsByUserId = ref<Record<string, HidCard[]>>({});
|
|
1131
|
-
const physicalCardErrorsByUserId = ref<Record<string, string>>({});
|
|
1132
|
-
const loadingCards = ref(false);
|
|
1133
|
-
const savingCard = ref(false);
|
|
1134
|
-
const enrollingCard = ref(false);
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
const
|
|
1141
|
-
|
|
1142
|
-
const
|
|
1143
|
-
const deletingCardId = ref("");
|
|
1144
|
-
const showPin = ref(false);
|
|
1145
|
-
const pinEnrolled = ref(false);
|
|
1146
|
-
const photoInput = ref<HTMLInputElement | null>(null);
|
|
1147
|
-
const cameraDialog = ref(false);
|
|
1148
|
-
const selectedPhotoFile = ref<File | null>(null);
|
|
1149
|
-
const removePhotoRequested = ref(false);
|
|
1150
|
-
/**
|
|
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
|
-
|
|
634
|
+
const selectedUser = ref<HidUser | null>(null);
|
|
635
|
+
const cardUser = ref<HidUser | null>(null);
|
|
636
|
+
const physicalCards = ref<HidCard[]>([]);
|
|
637
|
+
const physicalCardsByUserId = ref<Record<string, HidCard[]>>({});
|
|
638
|
+
const physicalCardErrorsByUserId = ref<Record<string, string>>({});
|
|
639
|
+
const loadingCards = ref(false);
|
|
640
|
+
const savingCard = ref(false);
|
|
641
|
+
const enrollingCard = ref(false);
|
|
642
|
+
const cardEnrollmentCancelled = ref(false);
|
|
643
|
+
const deletingCardId = ref("");
|
|
644
|
+
const showPin = ref(false);
|
|
645
|
+
const pinEnrolled = ref(false);
|
|
646
|
+
const photoInput = ref<HTMLInputElement | null>(null);
|
|
647
|
+
const cameraDialog = ref(false);
|
|
648
|
+
const selectedPhotoFile = ref<File | null>(null);
|
|
649
|
+
const removePhotoRequested = ref(false);
|
|
1967
650
|
/**
|
|
1968
|
-
*
|
|
1969
|
-
*
|
|
1970
|
-
*
|
|
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.
|
|
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.
|
|
1974
654
|
*/
|
|
1975
|
-
|
|
1976
|
-
REMEMBERED_SUBJECT_CATEGORY.value = form.subjectCategory as HidEnrolmentSubject;
|
|
1977
|
-
form.subjectId = "";
|
|
1978
|
-
form.subjectRole = "";
|
|
1979
|
-
form.name = "";
|
|
1980
|
-
residentConflict.value = null;
|
|
1981
|
-
|
|
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
|
-
}
|
|
655
|
+
const facialEnrolled = ref(false);
|
|
1991
656
|
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
657
|
+
function hasFacialEnrollment(user?: HidUser | null) {
|
|
658
|
+
return Boolean(
|
|
659
|
+
user?.metadata?.facialEnrolled ||
|
|
660
|
+
user?.metadata?.facialData ||
|
|
661
|
+
user?.metadata?.imageTimestamp
|
|
1996
662
|
);
|
|
1997
|
-
form.name = String(candidate?.name ?? "");
|
|
1998
|
-
form.subjectRole = String(candidate?.subtitle ?? "");
|
|
1999
|
-
await checkSubjectEnrollment();
|
|
2000
663
|
}
|
|
2001
|
-
|
|
2002
|
-
const
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
}
|
|
2007
|
-
return "Select block";
|
|
664
|
+
const removePinRequested = ref(false);
|
|
665
|
+
const snackbar = reactive({
|
|
666
|
+
show: false,
|
|
667
|
+
color: "success",
|
|
668
|
+
message: "",
|
|
2008
669
|
});
|
|
2009
670
|
|
|
2010
|
-
const
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
671
|
+
const form = reactive({
|
|
672
|
+
reader: "",
|
|
673
|
+
block: "",
|
|
674
|
+
level: "",
|
|
675
|
+
unit: "",
|
|
676
|
+
name: "",
|
|
677
|
+
hidUserId: "",
|
|
678
|
+
registration: "",
|
|
679
|
+
isAdministrator: false,
|
|
680
|
+
accessPin: "",
|
|
681
|
+
photoPreview: "",
|
|
682
|
+
subjectCategory: "resident" as THidPermissionCategory,
|
|
683
|
+
subjectId: "",
|
|
2020
684
|
});
|
|
2021
685
|
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
const
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
686
|
+
const route = useRoute();
|
|
687
|
+
const orgId = computed(() => String(route.params.org || ""));
|
|
688
|
+
const permissionCandidates = ref<THidPermissionCandidate[]>([]);
|
|
689
|
+
const loadingSubjects = ref(false);
|
|
690
|
+
const subjectCategoryOptions = [
|
|
691
|
+
{ title: "Resident", value: "resident" },
|
|
692
|
+
{ title: "Property management", value: "property_management" },
|
|
693
|
+
{ title: "Service provider", value: "service_provider" },
|
|
694
|
+
];
|
|
695
|
+
const subjectOptions = computed(() => permissionCandidates.value.map((candidate) => ({
|
|
696
|
+
title: candidate.subtitle ? `${candidate.name} · ${candidate.subtitle}` : candidate.name,
|
|
697
|
+
value: candidate.subjectId,
|
|
698
|
+
})));
|
|
2034
699
|
|
|
2035
700
|
const cardForm = reactive({
|
|
2036
701
|
cardType: "pacs" as "pacs" | "csn",
|
|
@@ -2040,37 +705,23 @@ const cardForm = reactive({
|
|
|
2040
705
|
});
|
|
2041
706
|
|
|
2042
707
|
const cardReader = computed(() => {
|
|
2043
|
-
const readerId = String(
|
|
2044
|
-
|
|
2045
|
-
);
|
|
2046
|
-
return (
|
|
2047
|
-
readers.value.find((reader) => String(reader._id) === readerId) || null
|
|
2048
|
-
);
|
|
708
|
+
const readerId = String(cardUser.value?.reader || selectedReaderId.value || "");
|
|
709
|
+
return readers.value.find((reader) => String(reader._id) === readerId) || null;
|
|
2049
710
|
});
|
|
2050
|
-
const cardProfileConfigured = computed(() =>
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
);
|
|
2055
|
-
const hybridCardProfile = computed(
|
|
2056
|
-
() => cardReader.value?.cardReadingMode === "csn_for_mifare"
|
|
2057
|
-
);
|
|
711
|
+
const cardProfileConfigured = computed(() => ["pacs", "csn", "csn_for_mifare"].includes(
|
|
712
|
+
String(cardReader.value?.cardReadingMode || ""),
|
|
713
|
+
));
|
|
714
|
+
const hybridCardProfile = computed(() => cardReader.value?.cardReadingMode === "csn_for_mifare");
|
|
2058
715
|
const hybridCardTypeOptions = [
|
|
2059
716
|
{ title: "MIFARE card (CSN)", value: "csn" },
|
|
2060
717
|
{ title: "Other card (PACS)", value: "pacs" },
|
|
2061
718
|
];
|
|
2062
719
|
const cardProfileLabel = computed(() => {
|
|
2063
720
|
const mode = String(cardReader.value?.cardReadingMode || "unconfigured");
|
|
2064
|
-
if (mode === "pacs")
|
|
2065
|
-
|
|
2066
|
-
if (mode === "csn")
|
|
2067
|
-
return `CSN · ${(
|
|
2068
|
-
cardReader.value?.csnByteOrder || "msb"
|
|
2069
|
-
).toUpperCase()} first`;
|
|
721
|
+
if (mode === "pacs") return `PACS · ${(cardReader.value?.pacsFormat || "raw").toUpperCase()}`;
|
|
722
|
+
if (mode === "csn") return `CSN · ${(cardReader.value?.csnByteOrder || "msb").toUpperCase()} first`;
|
|
2070
723
|
if (mode === "csn_for_mifare") {
|
|
2071
|
-
return `PACS for non-MIFARE cards · CSN for MIFARE (${(
|
|
2072
|
-
cardReader.value?.csnByteOrder || "msb"
|
|
2073
|
-
).toUpperCase()} first)`;
|
|
724
|
+
return `PACS for non-MIFARE cards · CSN for MIFARE (${(cardReader.value?.csnByteOrder || "msb").toUpperCase()} first)`;
|
|
2074
725
|
}
|
|
2075
726
|
return "Configure the card data mode on the HID Reader screen before assigning a card.";
|
|
2076
727
|
});
|
|
@@ -2087,8 +738,6 @@ const userHeaders = [
|
|
|
2087
738
|
{ title: "Name", value: "name", sortable: false },
|
|
2088
739
|
{ title: "Facial Data", value: "facialData", sortable: false },
|
|
2089
740
|
{ 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 },
|
|
2092
741
|
{ title: "Status", value: "status", sortable: false },
|
|
2093
742
|
{ title: "", value: "action-table", sortable: false },
|
|
2094
743
|
];
|
|
@@ -2100,12 +749,8 @@ const cardHeaders = [
|
|
|
2100
749
|
{ title: "", value: "action-table", sortable: false },
|
|
2101
750
|
];
|
|
2102
751
|
|
|
2103
|
-
const tableTitle = computed(() =>
|
|
2104
|
-
|
|
2105
|
-
);
|
|
2106
|
-
const tableHeaders = computed(() =>
|
|
2107
|
-
props.cardManagement ? cardHeaders : userHeaders
|
|
2108
|
-
);
|
|
752
|
+
const tableTitle = computed(() => props.cardManagement ? "HID Cards" : "HID Users");
|
|
753
|
+
const tableHeaders = computed(() => props.cardManagement ? cardHeaders : userHeaders);
|
|
2109
754
|
|
|
2110
755
|
/**
|
|
2111
756
|
* The list is paged on the SERVER, so the shared pager only moves the cursor -
|
|
@@ -2122,45 +767,14 @@ function reloadUsersFromFirstPage() {
|
|
|
2122
767
|
loadUsers();
|
|
2123
768
|
}
|
|
2124
769
|
|
|
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
|
-
|
|
2151
770
|
const readerOptions = computed(() =>
|
|
2152
771
|
readers.value.map((reader) => ({
|
|
2153
|
-
title: `${reader.name || reader.deviceId || reader._id} — ${
|
|
2154
|
-
reader.portalName || "Portal not configured"
|
|
2155
|
-
}`,
|
|
772
|
+
title: `${reader.name || reader.deviceId || reader._id} — ${reader.portalName || "Portal not configured"}`,
|
|
2156
773
|
value: reader._id,
|
|
2157
774
|
}))
|
|
2158
775
|
);
|
|
2159
|
-
const selectedReader = computed(
|
|
2160
|
-
() =>
|
|
2161
|
-
readers.value.find(
|
|
2162
|
-
(reader) => reader._id === (selectedReaderId.value || form.reader)
|
|
2163
|
-
) || null
|
|
776
|
+
const selectedReader = computed(() =>
|
|
777
|
+
readers.value.find((reader) => reader._id === (selectedReaderId.value || form.reader)) || null,
|
|
2164
778
|
);
|
|
2165
779
|
|
|
2166
780
|
const pageRange = computed(() => {
|
|
@@ -2187,28 +801,15 @@ async function loadReaders() {
|
|
|
2187
801
|
const response = await getReaders({ site: props.site, page: 1, limit: 100 });
|
|
2188
802
|
const responseRecord = toRecord(response);
|
|
2189
803
|
const responseData = toRecord(responseRecord.data);
|
|
2190
|
-
const items =
|
|
2191
|
-
responseRecord.items ?? responseData.items ?? responseData.readers ?? [];
|
|
804
|
+
const items = responseRecord.items ?? responseData.items ?? responseData.readers ?? [];
|
|
2192
805
|
readers.value = (Array.isArray(items) ? items : [])
|
|
2193
806
|
.map(toHidReader)
|
|
2194
807
|
.filter((reader): reader is HidReader => reader !== null);
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
an id the site does not own. */
|
|
2199
|
-
selectedReaderId.value = resolveForSite(props.site, readers.value);
|
|
808
|
+
if (!selectedReaderId.value && readers.value.length) {
|
|
809
|
+
selectedReaderId.value = readers.value[0]._id;
|
|
810
|
+
}
|
|
2200
811
|
}
|
|
2201
812
|
|
|
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
|
-
|
|
2212
813
|
async function loadUsers() {
|
|
2213
814
|
if (!selectedReaderId.value) {
|
|
2214
815
|
users.value = [];
|
|
@@ -2218,83 +819,32 @@ async function loadUsers() {
|
|
|
2218
819
|
return;
|
|
2219
820
|
}
|
|
2220
821
|
|
|
2221
|
-
const sequence = loadUsersSequence.value + 1;
|
|
2222
|
-
loadUsersSequence.value = sequence;
|
|
2223
822
|
loading.value = true;
|
|
2224
823
|
try {
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
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
|
-
}
|
|
824
|
+
const response = await getReaderUsers(selectedReaderId.value, {
|
|
825
|
+
page: page.value,
|
|
826
|
+
limit: limit.value,
|
|
827
|
+
search: search.value.trim(),
|
|
828
|
+
status: status.value.toLowerCase() as "mapped" | "unmapped" | "",
|
|
829
|
+
});
|
|
2257
830
|
const responseRecord = toRecord(response);
|
|
2258
831
|
const responseData = toRecord(responseRecord.data);
|
|
2259
832
|
const responseItems = responseRecord.items ?? responseData.items ?? [];
|
|
2260
|
-
const
|
|
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;
|
|
833
|
+
const pageItems = (Array.isArray(responseItems) ? responseItems : []).map(toHidUser);
|
|
2271
834
|
administratorUserIds.value = new Set(
|
|
2272
835
|
pageItems
|
|
2273
836
|
.filter((user) => user.isAdministrator === true)
|
|
2274
837
|
.map((user) => toHidNumericId(user.hidUserId))
|
|
2275
|
-
.filter((userId: number | undefined): userId is number =>
|
|
2276
|
-
Boolean(userId)
|
|
2277
|
-
)
|
|
838
|
+
.filter((userId: number | undefined): userId is number => Boolean(userId)),
|
|
2278
839
|
);
|
|
2279
840
|
users.value = pageItems;
|
|
2280
|
-
total.value =
|
|
2281
|
-
|
|
2282
|
-
|
|
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 ?? "");
|
|
841
|
+
total.value = Number(responseRecord.total ?? responseData.total ?? users.value.length);
|
|
842
|
+
pages.value = Number(responseRecord.pages ?? responseData.pages ?? 1);
|
|
843
|
+
serverPageRange.value = String(responseRecord.pageRange ?? responseData.pageRange ?? "");
|
|
2293
844
|
if (props.cardManagement) {
|
|
2294
845
|
await loadVisibleUserCards(users.value);
|
|
2295
846
|
}
|
|
2296
847
|
} catch (error) {
|
|
2297
|
-
if (sequence !== loadUsersSequence.value) return;
|
|
2298
848
|
console.error("Unable to load HID reader users:", error);
|
|
2299
849
|
showToast("Unable to load HID users from reader.", "error");
|
|
2300
850
|
users.value = [];
|
|
@@ -2305,12 +855,11 @@ async function loadUsers() {
|
|
|
2305
855
|
pages.value = 1;
|
|
2306
856
|
serverPageRange.value = "";
|
|
2307
857
|
} finally {
|
|
2308
|
-
|
|
2309
|
-
// clear it while that newer one is still running.
|
|
2310
|
-
if (sequence === loadUsersSequence.value) loading.value = false;
|
|
858
|
+
loading.value = false;
|
|
2311
859
|
}
|
|
2312
860
|
}
|
|
2313
861
|
|
|
862
|
+
|
|
2314
863
|
function resetForm() {
|
|
2315
864
|
form.reader = selectedReaderId.value || readers.value[0]?._id || "";
|
|
2316
865
|
form.block = "";
|
|
@@ -2321,10 +870,6 @@ function resetForm() {
|
|
|
2321
870
|
form.registration = "";
|
|
2322
871
|
form.isAdministrator = false;
|
|
2323
872
|
form.accessPin = "";
|
|
2324
|
-
form.password = "";
|
|
2325
|
-
passwordSet.value = false;
|
|
2326
|
-
showPassword.value = false;
|
|
2327
|
-
removePasswordRequested.value = false;
|
|
2328
873
|
pinEnrolled.value = false;
|
|
2329
874
|
showPin.value = false;
|
|
2330
875
|
removePinRequested.value = false;
|
|
@@ -2332,201 +877,34 @@ function resetForm() {
|
|
|
2332
877
|
selectedPhotoFile.value = null;
|
|
2333
878
|
removePhotoRequested.value = false;
|
|
2334
879
|
facialEnrolled.value = false;
|
|
2335
|
-
|
|
2336
|
-
// a shift of guards should not re-pick Member for each one.
|
|
2337
|
-
form.subjectCategory = REMEMBERED_SUBJECT_CATEGORY.value;
|
|
880
|
+
form.subjectCategory = "resident";
|
|
2338
881
|
form.subjectId = "";
|
|
2339
|
-
form.subjectRole = "";
|
|
2340
|
-
residentConflict.value = null;
|
|
2341
|
-
checkingResident.value = false;
|
|
2342
|
-
}
|
|
2343
|
-
|
|
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
882
|
}
|
|
2355
883
|
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
if (!
|
|
2359
|
-
|
|
2360
|
-
|
|
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;
|
|
884
|
+
async function loadSubjectCandidates() {
|
|
885
|
+
const readerId = form.reader || selectedReaderId.value;
|
|
886
|
+
if (!readerId || !orgId.value) {
|
|
887
|
+
permissionCandidates.value = [];
|
|
888
|
+
return;
|
|
2385
889
|
}
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
async function loadUnitPeople() {
|
|
2389
|
-
unitPeople.value = [];
|
|
2390
|
-
if (!form.unit) return;
|
|
2391
|
-
loadingUnitPeople.value = true;
|
|
890
|
+
loadingSubjects.value = true;
|
|
2392
891
|
try {
|
|
2393
|
-
const response = await
|
|
2394
|
-
|
|
2395
|
-
|
|
892
|
+
const response = await getPermissionCandidates(props.site, {
|
|
893
|
+
orgId: orgId.value,
|
|
894
|
+
readerId,
|
|
895
|
+
category: form.subjectCategory,
|
|
896
|
+
page: 1,
|
|
897
|
+
limit: 500,
|
|
2396
898
|
});
|
|
2397
|
-
|
|
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
|
-
);
|
|
899
|
+
permissionCandidates.value = response.items ?? response.data?.items ?? [];
|
|
2411
900
|
} finally {
|
|
2412
|
-
|
|
901
|
+
loadingSubjects.value = false;
|
|
2413
902
|
}
|
|
2414
903
|
}
|
|
2415
904
|
|
|
2416
|
-
function
|
|
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() {
|
|
905
|
+
async function onSubjectCategoryChanged() {
|
|
2440
906
|
form.subjectId = "";
|
|
2441
|
-
|
|
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
|
-
}
|
|
907
|
+
await loadSubjectCandidates();
|
|
2530
908
|
}
|
|
2531
909
|
|
|
2532
910
|
async function openEnroll() {
|
|
@@ -2538,16 +916,8 @@ async function openEnroll() {
|
|
|
2538
916
|
]);
|
|
2539
917
|
form.hidUserId = hidUserId;
|
|
2540
918
|
form.registration = registration;
|
|
919
|
+
await loadSubjectCandidates();
|
|
2541
920
|
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();
|
|
2551
921
|
}
|
|
2552
922
|
|
|
2553
923
|
async function openEdit(user: HidUser) {
|
|
@@ -2561,59 +931,31 @@ async function openEdit(user: HidUser) {
|
|
|
2561
931
|
form.registration = user.registration ?? "";
|
|
2562
932
|
form.isAdministrator = hasAdministratorRule(user);
|
|
2563
933
|
form.accessPin = "";
|
|
2564
|
-
form.password = "";
|
|
2565
|
-
showPassword.value = false;
|
|
2566
|
-
removePasswordRequested.value = false;
|
|
2567
|
-
passwordSet.value = Boolean(user.metadata?.passwordSet);
|
|
2568
934
|
pinEnrolled.value = Boolean(user.metadata?.pinEnrolled);
|
|
2569
935
|
removePinRequested.value = false;
|
|
2570
936
|
form.photoPreview = "";
|
|
2571
937
|
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
|
-
*/
|
|
2579
938
|
form.subjectCategory = user.person
|
|
2580
939
|
? "resident"
|
|
2581
940
|
: user.serviceProvider
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
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();
|
|
941
|
+
? "service_provider"
|
|
942
|
+
: "property_management";
|
|
943
|
+
form.subjectId = String(user.person || user.member || user.serviceProvider || "");
|
|
944
|
+
await loadSubjectCandidates();
|
|
2602
945
|
selectedPhotoFile.value = null;
|
|
2603
946
|
removePhotoRequested.value = false;
|
|
2604
947
|
formDialog.value = true;
|
|
2605
948
|
|
|
2606
949
|
try {
|
|
2607
|
-
const
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
pinEnrolled.value = Boolean(pinStatus?.data?.pinEnrolled);
|
|
2613
|
-
passwordSet.value = Boolean(passwordStatus?.data?.passwordSet);
|
|
950
|
+
const status = await getUserPinStatus(
|
|
951
|
+
form.reader,
|
|
952
|
+
toHidNumericId(user.hidUserId),
|
|
953
|
+
);
|
|
954
|
+
pinEnrolled.value = Boolean(status?.data?.pinEnrolled);
|
|
2614
955
|
} catch {
|
|
2615
956
|
// Preserve the last known status if the reader is temporarily offline.
|
|
2616
957
|
}
|
|
958
|
+
|
|
2617
959
|
}
|
|
2618
960
|
|
|
2619
961
|
function openView(user: HidUser) {
|
|
@@ -2688,55 +1030,16 @@ function onPhotoChange(event: Event) {
|
|
|
2688
1030
|
async function saveUser() {
|
|
2689
1031
|
const readerId =
|
|
2690
1032
|
selectedReaderId.value || form.reader || readers.value[0]?._id;
|
|
2691
|
-
|
|
2692
|
-
|
|
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
|
-
);
|
|
1033
|
+
if (!readerId || !form.name || !form.hidUserId || !form.registration || !form.subjectId) {
|
|
1034
|
+
showToast("Please complete the required HID user fields.", "error");
|
|
2716
1035
|
return;
|
|
2717
1036
|
}
|
|
2718
1037
|
if (form.accessPin && !/^\d{1,32}$/.test(form.accessPin)) {
|
|
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
|
-
);
|
|
1038
|
+
showToast("Access PIN must contain numbers only.", "error");
|
|
2730
1039
|
return;
|
|
2731
1040
|
}
|
|
2732
|
-
|
|
2733
|
-
|
|
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");
|
|
1041
|
+
if (selectedPhotoFile.value && (!selectedReader.value?.portalId || !selectedReader.value?.portalName)) {
|
|
1042
|
+
showToast("Reconnect this HID reader and select a portal before enrolling facial recognition.", "error");
|
|
2740
1043
|
return;
|
|
2741
1044
|
}
|
|
2742
1045
|
|
|
@@ -2753,17 +1056,22 @@ async function saveUser() {
|
|
|
2753
1056
|
return;
|
|
2754
1057
|
}
|
|
2755
1058
|
|
|
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
|
-
|
|
2763
1059
|
const payload = {
|
|
2764
1060
|
hidUserId: String(hidUser.id),
|
|
2765
1061
|
registration: form.registration,
|
|
2766
|
-
|
|
1062
|
+
person: form.subjectCategory === "resident" ? form.subjectId : "",
|
|
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",
|
|
2767
1075
|
status: "active" as const,
|
|
2768
1076
|
metadata: {
|
|
2769
1077
|
name: form.name,
|
|
@@ -2780,7 +1088,6 @@ async function saveUser() {
|
|
|
2780
1088
|
imageTimestamp: selectedUser.value?.metadata?.imageTimestamp || "",
|
|
2781
1089
|
facialScores: selectedUser.value?.metadata?.facialScores || {},
|
|
2782
1090
|
pinEnrolled: pinEnrolled.value,
|
|
2783
|
-
passwordSet: passwordSet.value,
|
|
2784
1091
|
hidPhysicalCards: selectedUser.value?.metadata?.hidPhysicalCards || [],
|
|
2785
1092
|
},
|
|
2786
1093
|
};
|
|
@@ -2790,22 +1097,17 @@ async function saveUser() {
|
|
|
2790
1097
|
const facialResult = await syncFacialImage(readerId, hidUser.id);
|
|
2791
1098
|
applyFacialResult(payload.metadata, facialResult);
|
|
2792
1099
|
payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
|
|
2793
|
-
payload.metadata.passwordSet = await syncPassword(readerId, hidUser.id);
|
|
2794
1100
|
if (selectedUser.value._id) {
|
|
2795
1101
|
await updateIdentity(selectedUser.value._id, payload);
|
|
2796
|
-
} else
|
|
1102
|
+
} else {
|
|
2797
1103
|
await saveIdentityOnReader(readerId, payload);
|
|
2798
1104
|
}
|
|
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.
|
|
2802
1105
|
} else {
|
|
2803
1106
|
const created = await createHidUserOnReader(readerId, hidUser);
|
|
2804
1107
|
if (created) newlyCreatedHidUserId = hidUser.id;
|
|
2805
1108
|
const facialResult = await syncFacialImage(readerId, hidUser.id);
|
|
2806
1109
|
applyFacialResult(payload.metadata, facialResult);
|
|
2807
1110
|
payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
|
|
2808
|
-
payload.metadata.passwordSet = await syncPassword(readerId, hidUser.id);
|
|
2809
1111
|
await saveIdentityOnReader(readerId, payload);
|
|
2810
1112
|
selectedReaderId.value = readerId;
|
|
2811
1113
|
}
|
|
@@ -2826,10 +1128,7 @@ async function saveUser() {
|
|
|
2826
1128
|
where: { users: { id: newlyCreatedHidUserId } },
|
|
2827
1129
|
});
|
|
2828
1130
|
} catch (rollbackError) {
|
|
2829
|
-
console.error(
|
|
2830
|
-
"Unable to roll back the partially enrolled HID user:",
|
|
2831
|
-
rollbackError
|
|
2832
|
-
);
|
|
1131
|
+
console.error("Unable to roll back the partially enrolled HID user:", rollbackError);
|
|
2833
1132
|
}
|
|
2834
1133
|
}
|
|
2835
1134
|
console.error("Unable to save HID user:", error);
|
|
@@ -2844,25 +1143,6 @@ async function saveUser() {
|
|
|
2844
1143
|
}
|
|
2845
1144
|
}
|
|
2846
1145
|
|
|
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
|
-
|
|
2866
1146
|
async function syncAccessPin(readerId: string, hidUserId: number) {
|
|
2867
1147
|
if (removePinRequested.value) {
|
|
2868
1148
|
await deleteUserPin(readerId, hidUserId);
|
|
@@ -2930,59 +1210,11 @@ function applyFacialResult(
|
|
|
2930
1210
|
}
|
|
2931
1211
|
}
|
|
2932
1212
|
|
|
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
|
-
|
|
2975
1213
|
async function deleteUser() {
|
|
2976
1214
|
if (!selectedUser.value) return;
|
|
2977
1215
|
|
|
2978
1216
|
deleting.value = true;
|
|
2979
1217
|
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
|
-
);
|
|
2986
1218
|
await syncHidUserRole(
|
|
2987
1219
|
selectedReaderId.value,
|
|
2988
1220
|
toHidNumericId(selectedUser.value.hidUserId),
|
|
@@ -2990,13 +1222,11 @@ async function deleteUser() {
|
|
|
2990
1222
|
);
|
|
2991
1223
|
await deleteUserPin(
|
|
2992
1224
|
selectedReaderId.value,
|
|
2993
|
-
toHidNumericId(selectedUser.value.hidUserId)
|
|
1225
|
+
toHidNumericId(selectedUser.value.hidUserId),
|
|
2994
1226
|
);
|
|
2995
1227
|
await deleteHidUserFromReader(selectedReaderId.value, selectedUser.value);
|
|
2996
|
-
if (
|
|
2997
|
-
|
|
2998
|
-
// through, which is what stops the next reconcile rebuilding them.
|
|
2999
|
-
await deleteIdentity(identityId);
|
|
1228
|
+
if (selectedUser.value._id) {
|
|
1229
|
+
await deleteIdentity(selectedUser.value._id);
|
|
3000
1230
|
}
|
|
3001
1231
|
deleteDialog.value = false;
|
|
3002
1232
|
await loadUsers();
|
|
@@ -3011,7 +1241,7 @@ async function deleteUser() {
|
|
|
3011
1241
|
|
|
3012
1242
|
async function saveIdentityOnReader(
|
|
3013
1243
|
readerId: string,
|
|
3014
|
-
payload: HidIdentityPayload
|
|
1244
|
+
payload: HidIdentityPayload,
|
|
3015
1245
|
) {
|
|
3016
1246
|
const existingIdentity = await findExistingIdentityOnReader(
|
|
3017
1247
|
readerId,
|
|
@@ -3023,11 +1253,7 @@ async function saveIdentityOnReader(
|
|
|
3023
1253
|
}
|
|
3024
1254
|
|
|
3025
1255
|
try {
|
|
3026
|
-
await createIdentity(readerId, {
|
|
3027
|
-
...payload,
|
|
3028
|
-
site: props.site,
|
|
3029
|
-
...(props.org ? { orgId: props.org } : {}),
|
|
3030
|
-
});
|
|
1256
|
+
await createIdentity(readerId, { ...payload, site: props.site });
|
|
3031
1257
|
} catch (error: unknown) {
|
|
3032
1258
|
const message = getHidErrorMessage(error);
|
|
3033
1259
|
if (!message.toLowerCase().includes("already exists")) throw error;
|
|
@@ -3043,7 +1269,7 @@ async function saveIdentityOnReader(
|
|
|
3043
1269
|
|
|
3044
1270
|
async function findExistingIdentityOnReader(
|
|
3045
1271
|
readerId: string,
|
|
3046
|
-
payload: HidIdentityPayload
|
|
1272
|
+
payload: HidIdentityPayload,
|
|
3047
1273
|
) {
|
|
3048
1274
|
const response = await getIdentities(readerId, {
|
|
3049
1275
|
page: 1,
|
|
@@ -3052,11 +1278,8 @@ async function findExistingIdentityOnReader(
|
|
|
3052
1278
|
});
|
|
3053
1279
|
const responseRecord = toRecord(response);
|
|
3054
1280
|
const responseData = toRecord(responseRecord.data);
|
|
3055
|
-
const responseItems =
|
|
3056
|
-
|
|
3057
|
-
const identities = (Array.isArray(responseItems) ? responseItems : []).map(
|
|
3058
|
-
toHidUser
|
|
3059
|
-
);
|
|
1281
|
+
const responseItems = responseRecord.items ?? responseData.items ?? responseData.identities ?? [];
|
|
1282
|
+
const identities = (Array.isArray(responseItems) ? responseItems : []).map(toHidUser);
|
|
3060
1283
|
const hidUserId = String(payload.hidUserId || "");
|
|
3061
1284
|
const registration = String(payload.registration || "");
|
|
3062
1285
|
const cardNo = String(payload.cardNo || "");
|
|
@@ -3073,7 +1296,7 @@ async function findExistingIdentityOnReader(
|
|
|
3073
1296
|
|
|
3074
1297
|
async function createHidUserOnReader(
|
|
3075
1298
|
readerId: string,
|
|
3076
|
-
user: { id?: number; name: string; registration: string }
|
|
1299
|
+
user: { id?: number; name: string; registration: string },
|
|
3077
1300
|
) {
|
|
3078
1301
|
const existing = await getHidUserFromReader(readerId, user.id);
|
|
3079
1302
|
if (existing) {
|
|
@@ -3107,7 +1330,7 @@ async function getHidUserFromReader(readerId: string, id: unknown) {
|
|
|
3107
1330
|
|
|
3108
1331
|
async function updateHidUserOnReader(
|
|
3109
1332
|
readerId: string,
|
|
3110
|
-
user: { id?: number; name: string; registration: string }
|
|
1333
|
+
user: { id?: number; name: string; registration: string },
|
|
3111
1334
|
) {
|
|
3112
1335
|
const { id, ...values } = user;
|
|
3113
1336
|
await runObjectOperation(readerId, {
|
|
@@ -3122,7 +1345,10 @@ async function updateHidUserOnReader(
|
|
|
3122
1345
|
});
|
|
3123
1346
|
}
|
|
3124
1347
|
|
|
3125
|
-
async function deleteHidUserFromReader(
|
|
1348
|
+
async function deleteHidUserFromReader(
|
|
1349
|
+
readerId: string,
|
|
1350
|
+
user: HidUser,
|
|
1351
|
+
) {
|
|
3126
1352
|
const id = toHidNumericId(user.hidUserId);
|
|
3127
1353
|
if (!readerId || !id) return;
|
|
3128
1354
|
|
|
@@ -3210,9 +1436,7 @@ async function getHidUserRole(readerId: string, userId: number) {
|
|
|
3210
1436
|
return Array.isArray(roles) ? roles[0] : undefined;
|
|
3211
1437
|
}
|
|
3212
1438
|
|
|
3213
|
-
function toHidUserObject(
|
|
3214
|
-
identity: Pick<HidUser, "hidUserId" | "name" | "registration">
|
|
3215
|
-
) {
|
|
1439
|
+
function toHidUserObject(identity: Pick<HidUser, "hidUserId" | "name" | "registration">) {
|
|
3216
1440
|
return {
|
|
3217
1441
|
id: toHidNumericId(identity.hidUserId),
|
|
3218
1442
|
name:
|
|
@@ -3223,23 +1447,6 @@ function toHidUserObject(
|
|
|
3223
1447
|
};
|
|
3224
1448
|
}
|
|
3225
1449
|
|
|
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
|
-
|
|
3243
1450
|
function toHidNumericId(value: unknown) {
|
|
3244
1451
|
const raw = String(value ?? "").trim();
|
|
3245
1452
|
if (!raw) return undefined;
|
|
@@ -3268,19 +1475,16 @@ async function loadVisibleUserCards(items: HidUser[]) {
|
|
|
3268
1475
|
const response = await getUserCards(selectedReaderId.value, hidUserId);
|
|
3269
1476
|
const responseData = toRecord(toRecord(response).data);
|
|
3270
1477
|
const cards = responseData.cards;
|
|
3271
|
-
return [
|
|
3272
|
-
String(hidUserId),
|
|
3273
|
-
(Array.isArray(cards) ? cards : []).map(toHidCard),
|
|
3274
|
-
] as const;
|
|
1478
|
+
return [String(hidUserId), (Array.isArray(cards) ? cards : []).map(toHidCard)] as const;
|
|
3275
1479
|
} catch (error: unknown) {
|
|
3276
1480
|
cardErrors[String(hidUserId)] = getHidErrorMessage(error);
|
|
3277
1481
|
return [String(hidUserId), []] as const;
|
|
3278
1482
|
}
|
|
3279
|
-
})
|
|
1483
|
+
}),
|
|
3280
1484
|
);
|
|
3281
1485
|
|
|
3282
1486
|
physicalCardsByUserId.value = Object.fromEntries(
|
|
3283
|
-
entries.filter(([hidUserId]) => Boolean(hidUserId))
|
|
1487
|
+
entries.filter(([hidUserId]) => Boolean(hidUserId)),
|
|
3284
1488
|
);
|
|
3285
1489
|
physicalCardErrorsByUserId.value = cardErrors;
|
|
3286
1490
|
}
|
|
@@ -3292,16 +1496,12 @@ function getUserPhysicalCards(user: HidUser) {
|
|
|
3292
1496
|
|
|
3293
1497
|
function getUserCardLoadError(user: HidUser) {
|
|
3294
1498
|
const hidUserId = toHidNumericId(user.hidUserId);
|
|
3295
|
-
return hidUserId
|
|
3296
|
-
? physicalCardErrorsByUserId.value[String(hidUserId)] || ""
|
|
3297
|
-
: "";
|
|
1499
|
+
return hidUserId ? physicalCardErrorsByUserId.value[String(hidUserId)] || "" : "";
|
|
3298
1500
|
}
|
|
3299
1501
|
|
|
3300
1502
|
async function openCardManager(user: HidUser) {
|
|
3301
1503
|
cardUser.value = user;
|
|
3302
|
-
const reader = readers.value.find(
|
|
3303
|
-
(item) => String(item._id) === String(user.reader || selectedReaderId.value)
|
|
3304
|
-
);
|
|
1504
|
+
const reader = readers.value.find((item) => String(item._id) === String(user.reader || selectedReaderId.value));
|
|
3305
1505
|
cardForm.cardType = reader?.cardReadingMode === "pacs" ? "pacs" : "csn";
|
|
3306
1506
|
cardForm.facilityCode = "";
|
|
3307
1507
|
cardForm.cardNumber = "";
|
|
@@ -3312,9 +1512,7 @@ async function openCardManager(user: HidUser) {
|
|
|
3312
1512
|
}
|
|
3313
1513
|
|
|
3314
1514
|
function getCardReaderAndUser() {
|
|
3315
|
-
const readerId = String(
|
|
3316
|
-
cardUser.value?.reader || selectedReaderId.value || ""
|
|
3317
|
-
);
|
|
1515
|
+
const readerId = String(cardUser.value?.reader || selectedReaderId.value || "");
|
|
3318
1516
|
const hidUserId = toHidNumericId(cardUser.value?.hidUserId);
|
|
3319
1517
|
return { readerId, hidUserId };
|
|
3320
1518
|
}
|
|
@@ -3337,11 +1535,7 @@ async function loadPhysicalCards() {
|
|
|
3337
1535
|
}
|
|
3338
1536
|
}
|
|
3339
1537
|
|
|
3340
|
-
function parsePhysicalCardInteger(
|
|
3341
|
-
value: string,
|
|
3342
|
-
label: string,
|
|
3343
|
-
maximum: bigint
|
|
3344
|
-
) {
|
|
1538
|
+
function parsePhysicalCardInteger(value: string, label: string, maximum: bigint) {
|
|
3345
1539
|
if (!/^\d+$/.test(value.trim())) {
|
|
3346
1540
|
showToast(`${label} must contain numbers only.`, "error");
|
|
3347
1541
|
return null;
|
|
@@ -3362,27 +1556,14 @@ async function assignPhysicalCard() {
|
|
|
3362
1556
|
| { cardType: "pacs"; facilityCode: string; cardNumber: string }
|
|
3363
1557
|
| { cardType: "csn"; cardValue: string };
|
|
3364
1558
|
if (cardForm.cardType === "pacs") {
|
|
3365
|
-
const facilityCode = parsePhysicalCardInteger(
|
|
3366
|
-
|
|
3367
|
-
"Facility Code",
|
|
3368
|
-
4294967295n
|
|
3369
|
-
);
|
|
3370
|
-
const cardNumber = parsePhysicalCardInteger(
|
|
3371
|
-
cardForm.cardNumber,
|
|
3372
|
-
"Card Number",
|
|
3373
|
-
4294967295n
|
|
3374
|
-
);
|
|
1559
|
+
const facilityCode = parsePhysicalCardInteger(cardForm.facilityCode, "Facility Code", 4294967295n);
|
|
1560
|
+
const cardNumber = parsePhysicalCardInteger(cardForm.cardNumber, "Card Number", 4294967295n);
|
|
3375
1561
|
if (facilityCode === null || cardNumber === null) return;
|
|
3376
1562
|
payload = { cardType: "pacs", facilityCode, cardNumber };
|
|
3377
1563
|
} else {
|
|
3378
|
-
const cardValue = parsePhysicalCardInteger(
|
|
3379
|
-
cardForm.cardValue,
|
|
3380
|
-
"CSN",
|
|
3381
|
-
18446744073709551615n
|
|
3382
|
-
);
|
|
1564
|
+
const cardValue = parsePhysicalCardInteger(cardForm.cardValue, "CSN", 18446744073709551615n);
|
|
3383
1565
|
if (cardValue === null || BigInt(cardValue) <= 0n) {
|
|
3384
|
-
if (cardValue === "0")
|
|
3385
|
-
showToast("CSN must be greater than zero.", "error");
|
|
1566
|
+
if (cardValue === "0") showToast("CSN must be greater than zero.", "error");
|
|
3386
1567
|
return;
|
|
3387
1568
|
}
|
|
3388
1569
|
payload = { cardType: "csn", cardValue };
|
|
@@ -3404,14 +1585,6 @@ async function assignPhysicalCard() {
|
|
|
3404
1585
|
}
|
|
3405
1586
|
}
|
|
3406
1587
|
|
|
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
|
-
*/
|
|
3415
1588
|
async function enrollPhysicalCard() {
|
|
3416
1589
|
const { readerId, hidUserId } = getCardReaderAndUser();
|
|
3417
1590
|
if (!readerId || !hidUserId) return;
|
|
@@ -3424,8 +1597,6 @@ async function enrollPhysicalCard() {
|
|
|
3424
1597
|
await loadUsers();
|
|
3425
1598
|
showToast("Physical HID card read and enrolled on the reader.");
|
|
3426
1599
|
} 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.
|
|
3429
1600
|
if (!cardEnrollmentCancelled.value) {
|
|
3430
1601
|
showToast(getHidErrorMessage(error), "error");
|
|
3431
1602
|
}
|
|
@@ -3434,55 +1605,20 @@ async function enrollPhysicalCard() {
|
|
|
3434
1605
|
}
|
|
3435
1606
|
}
|
|
3436
1607
|
|
|
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
|
-
*/
|
|
3461
1608
|
async function cancelPhysicalCardEnrollment() {
|
|
3462
1609
|
const { readerId } = getCardReaderAndUser();
|
|
3463
1610
|
if (!readerId) return;
|
|
3464
|
-
// Set first, awaited second: the enrol can reject at any point from here.
|
|
3465
1611
|
cardEnrollmentCancelled.value = true;
|
|
3466
|
-
cancellingCard.value = true;
|
|
3467
1612
|
try {
|
|
3468
1613
|
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;
|
|
3473
1614
|
showToast("Physical HID card reading cancelled.");
|
|
3474
1615
|
} 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.
|
|
3477
1616
|
showToast(getHidErrorMessage(error), "error");
|
|
3478
|
-
} finally {
|
|
3479
|
-
cancellingCard.value = false;
|
|
3480
1617
|
}
|
|
3481
1618
|
}
|
|
3482
1619
|
|
|
3483
1620
|
async function removePhysicalCard(card: HidCard) {
|
|
3484
|
-
if (!window.confirm(`Remove physical HID card ${formatPhysicalCard(card)}?`))
|
|
3485
|
-
return;
|
|
1621
|
+
if (!window.confirm(`Remove physical HID card ${formatPhysicalCard(card)}?`)) return;
|
|
3486
1622
|
const { readerId, hidUserId } = getCardReaderAndUser();
|
|
3487
1623
|
if (!readerId || !hidUserId || !card?.id) return;
|
|
3488
1624
|
|
|
@@ -3508,38 +1644,13 @@ function formatPhysicalCard(card: HidCard) {
|
|
|
3508
1644
|
}
|
|
3509
1645
|
|
|
3510
1646
|
function getPhysicalCardSubtitle(card: HidCard) {
|
|
3511
|
-
const source =
|
|
3512
|
-
card?.source === "reader"
|
|
3513
|
-
? "Read at reader"
|
|
3514
|
-
: card?.source === "manual"
|
|
3515
|
-
? "Manual"
|
|
3516
|
-
: "On device";
|
|
1647
|
+
const source = card?.source === "reader" ? "Read at reader" : card?.source === "manual" ? "Manual" : "On device";
|
|
3517
1648
|
return `${source} - Raw value ${card?.value || "N/A"}`;
|
|
3518
1649
|
}
|
|
3519
1650
|
|
|
3520
1651
|
function buildUnitLabel() {
|
|
3521
|
-
|
|
3522
|
-
|
|
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(" / ");
|
|
1652
|
+
const parts = [form.block, form.level, form.unit].filter(Boolean);
|
|
1653
|
+
return parts.length ? `BLK ${parts.join("/")}` : "";
|
|
3543
1654
|
}
|
|
3544
1655
|
|
|
3545
1656
|
function getName(user?: HidUser | null) {
|
|
@@ -3626,28 +1737,27 @@ function normalizeHidUsers(response: unknown): HidUser[] {
|
|
|
3626
1737
|
const responseRecord = toRecord(response);
|
|
3627
1738
|
const responseData = toRecord(responseRecord.data);
|
|
3628
1739
|
const nestedData = toRecord(responseData.data);
|
|
3629
|
-
const items =
|
|
3630
|
-
responseData.users ?? nestedData.users ?? responseRecord.users ?? [];
|
|
1740
|
+
const items = responseData.users ?? nestedData.users ?? responseRecord.users ?? [];
|
|
3631
1741
|
|
|
3632
1742
|
return Array.isArray(items)
|
|
3633
1743
|
? items.map((value) => {
|
|
3634
1744
|
const user = toRecord(value);
|
|
3635
1745
|
return {
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
1746
|
+
_rowKey: `reader-${user.id}`,
|
|
1747
|
+
hidUserId: String(user.id || ""),
|
|
1748
|
+
rawHidUserId: toText(user.id),
|
|
1749
|
+
registration: toText(user.registration),
|
|
1750
|
+
userTypeId: toText(user.user_type_id),
|
|
1751
|
+
cardNo: toText(user.card_value || user.cardNo),
|
|
1752
|
+
name: toText(user.name),
|
|
1753
|
+
metadata: {
|
|
3642
1754
|
name: toText(user.name),
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
hidReaderSource: true,
|
|
3650
|
-
},
|
|
1755
|
+
imageTimestamp: user.image_timestamp
|
|
1756
|
+
? String(user.image_timestamp)
|
|
1757
|
+
: "",
|
|
1758
|
+
lastAccessAt: fromUnixSeconds(user.last_access),
|
|
1759
|
+
hidReaderSource: true,
|
|
1760
|
+
},
|
|
3651
1761
|
};
|
|
3652
1762
|
})
|
|
3653
1763
|
: [];
|
|
@@ -3655,7 +1765,7 @@ function normalizeHidUsers(response: unknown): HidUser[] {
|
|
|
3655
1765
|
|
|
3656
1766
|
function mergeReaderUsersWithIdentities(
|
|
3657
1767
|
readerUsers: HidUser[],
|
|
3658
|
-
identities: HidUser[]
|
|
1768
|
+
identities: HidUser[],
|
|
3659
1769
|
) {
|
|
3660
1770
|
const identityById = new Map<string, HidUser>();
|
|
3661
1771
|
identities.forEach((identity) => {
|
|
@@ -3698,13 +1808,7 @@ function getMappingStatus(user: HidUser) {
|
|
|
3698
1808
|
}
|
|
3699
1809
|
|
|
3700
1810
|
function hasMappedRecord(user: HidUser) {
|
|
3701
|
-
return Boolean(
|
|
3702
|
-
user?.person ||
|
|
3703
|
-
user?.user ||
|
|
3704
|
-
user?.member ||
|
|
3705
|
-
user?.serviceProvider ||
|
|
3706
|
-
user?.visitor
|
|
3707
|
-
);
|
|
1811
|
+
return Boolean(user?.person || user?.user || user?.member || user?.serviceProvider || user?.visitor);
|
|
3708
1812
|
}
|
|
3709
1813
|
|
|
3710
1814
|
function formatDate(value?: string) {
|
|
@@ -3736,26 +1840,18 @@ function getHidErrorMessage(error: unknown) {
|
|
|
3736
1840
|
const cause = toRecord(errorRecord.cause);
|
|
3737
1841
|
const causeResponse = toRecord(cause.response);
|
|
3738
1842
|
const data = toRecord(
|
|
3739
|
-
errorRecord.data ||
|
|
3740
|
-
response._data ||
|
|
3741
|
-
response.data ||
|
|
3742
|
-
cause.data ||
|
|
3743
|
-
causeResponse._data
|
|
1843
|
+
errorRecord.data || response._data || response.data || cause.data || causeResponse._data,
|
|
3744
1844
|
);
|
|
3745
1845
|
|
|
3746
1846
|
return toText(
|
|
3747
|
-
data.message ||
|
|
3748
|
-
|
|
3749
|
-
data.statusMessage ||
|
|
3750
|
-
errorRecord.statusMessage ||
|
|
3751
|
-
errorRecord.message,
|
|
3752
|
-
"Unable to save HID user."
|
|
1847
|
+
data.message || data.error || data.statusMessage || errorRecord.statusMessage || errorRecord.message,
|
|
1848
|
+
"Unable to save HID user.",
|
|
3753
1849
|
);
|
|
3754
1850
|
}
|
|
3755
1851
|
|
|
3756
1852
|
function toRecord(value: unknown): Record<string, unknown> {
|
|
3757
1853
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
3758
|
-
?
|
|
1854
|
+
? value as Record<string, unknown>
|
|
3759
1855
|
: {};
|
|
3760
1856
|
}
|
|
3761
1857
|
|
|
@@ -3801,24 +1897,10 @@ function toHidCard(value: unknown): HidCard {
|
|
|
3801
1897
|
return {
|
|
3802
1898
|
...card,
|
|
3803
1899
|
id: toText(card.id) || undefined,
|
|
3804
|
-
cardType:
|
|
3805
|
-
|
|
3806
|
-
? card.cardType
|
|
3807
|
-
: undefined,
|
|
3808
|
-
source:
|
|
3809
|
-
card.source === "reader" ||
|
|
3810
|
-
card.source === "manual" ||
|
|
3811
|
-
card.source === "device"
|
|
3812
|
-
? card.source
|
|
3813
|
-
: undefined,
|
|
1900
|
+
cardType: card.cardType === "pacs" || card.cardType === "csn" ? card.cardType : undefined,
|
|
1901
|
+
source: card.source === "reader" || card.source === "manual" || card.source === "device" ? card.source : undefined,
|
|
3814
1902
|
};
|
|
3815
1903
|
}
|
|
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
|
-
});
|
|
3822
1904
|
</script>
|
|
3823
1905
|
|
|
3824
1906
|
<style scoped>
|
|
@@ -3855,6 +1937,12 @@ watch(selectedReaderId, (value) => {
|
|
|
3855
1937
|
max-width: 320px;
|
|
3856
1938
|
}
|
|
3857
1939
|
|
|
1940
|
+
.small-title {
|
|
1941
|
+
font-size: 14px;
|
|
1942
|
+
font-weight: 700;
|
|
1943
|
+
color: var(--text);
|
|
1944
|
+
}
|
|
1945
|
+
|
|
3858
1946
|
/* Was `#4f5a66` / `#e53935` - the same two greys on both themes. */
|
|
3859
1947
|
.field-label {
|
|
3860
1948
|
color: var(--text2);
|
|
@@ -3876,83 +1964,12 @@ watch(selectedReaderId, (value) => {
|
|
|
3876
1964
|
line-height: 1.45;
|
|
3877
1965
|
}
|
|
3878
1966
|
|
|
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
|
-
|
|
3926
1967
|
.photo-wrap {
|
|
3927
1968
|
display: grid;
|
|
3928
1969
|
place-items: center;
|
|
3929
1970
|
margin-bottom: 14px;
|
|
3930
1971
|
}
|
|
3931
1972
|
|
|
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
|
-
|
|
3956
1973
|
.photo-button {
|
|
3957
1974
|
width: 120px;
|
|
3958
1975
|
height: 120px;
|
|
@@ -3986,6 +2003,7 @@ watch(selectedReaderId, (value) => {
|
|
|
3986
2003
|
box-shadow: var(--shadow-menu);
|
|
3987
2004
|
}
|
|
3988
2005
|
|
|
2006
|
+
|
|
3989
2007
|
.admin-rule-indicator {
|
|
3990
2008
|
display: inline-flex;
|
|
3991
2009
|
vertical-align: middle;
|
|
@@ -4125,6 +2143,25 @@ watch(selectedReaderId, (value) => {
|
|
|
4125
2143
|
color: var(--muted);
|
|
4126
2144
|
}
|
|
4127
2145
|
|
|
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
|
+
|
|
4128
2165
|
@media (max-width: 960px) {
|
|
4129
2166
|
.hid-user-filters :deep(.app-field),
|
|
4130
2167
|
.hid-user-filters :deep(.app-select) {
|