@7365admin1/layer-common 4.82.1-staging.479 → 4.82.1-staging.480
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.
|
@@ -363,7 +363,7 @@
|
|
|
363
363
|
access is bound by user id, so there is nothing to bind. The note
|
|
364
364
|
below says how many were left out rather than letting a missing
|
|
365
365
|
colleague read as a bug. -->
|
|
366
|
-
<template v-if="!selectedUser &&
|
|
366
|
+
<template v-if="!selectedUser && isMemberSubject">
|
|
367
367
|
<div class="field-label">Member <span>*</span></div>
|
|
368
368
|
<AppSelect
|
|
369
369
|
v-model="form.subjectId"
|
|
@@ -389,6 +389,59 @@
|
|
|
389
389
|
</p>
|
|
390
390
|
</template>
|
|
391
391
|
|
|
392
|
+
<!-- SERVICE PROVIDER STAFF. One person from a provider company, and
|
|
393
|
+
never the company: granting a company puts every employee of the
|
|
394
|
+
firm on the door, including the ones who do not attend this site.
|
|
395
|
+
The list, the service filter and the filter's "All services"
|
|
396
|
+
default are the Members > Service Providers screen's own, from the
|
|
397
|
+
same endpoint and through the same `filterProviderMembers` rule,
|
|
398
|
+
so the two screens cannot disagree about who works here.
|
|
399
|
+
Unpaged - the endpoint returns the site's people in one response -
|
|
400
|
+
so searching and filtering cost no request. -->
|
|
401
|
+
<template v-if="!selectedUser && isProviderSubject">
|
|
402
|
+
<div class="field-label">Service</div>
|
|
403
|
+
<AppSelect
|
|
404
|
+
v-model="providerService"
|
|
405
|
+
:items="providerServiceOptions"
|
|
406
|
+
klass="mb-3"
|
|
407
|
+
@update:model-value="onProviderServiceChanged"
|
|
408
|
+
/>
|
|
409
|
+
|
|
410
|
+
<div class="field-label">Person <span>*</span></div>
|
|
411
|
+
<AppSelect
|
|
412
|
+
v-model="form.subjectId"
|
|
413
|
+
:items="providerOptions"
|
|
414
|
+
searchable
|
|
415
|
+
search-placeholder="Search by name, company or role"
|
|
416
|
+
:placeholder="providerPlaceholder"
|
|
417
|
+
klass="mb-3"
|
|
418
|
+
@update:model-value="onProviderChanged"
|
|
419
|
+
/>
|
|
420
|
+
|
|
421
|
+
<!-- ACCESS ONLY, and the button says so. Shown here and nowhere
|
|
422
|
+
else: it is the one subject type that arrives a shift at a time,
|
|
423
|
+
and the people it adds fill in their own credentials from their
|
|
424
|
+
app afterwards. -->
|
|
425
|
+
<div class="hid-bulk-cta mb-3">
|
|
426
|
+
<AppButton
|
|
427
|
+
variant="ghost"
|
|
428
|
+
icon="mdi-account-multiple-plus-outline"
|
|
429
|
+
:disabled="loadingProviders"
|
|
430
|
+
@click="openBulkAdd"
|
|
431
|
+
>
|
|
432
|
+
Add Users in Bulk
|
|
433
|
+
</AppButton>
|
|
434
|
+
<span class="field-hint">
|
|
435
|
+
Gives access to many people at once. They add their own face, QR
|
|
436
|
+
or PIN from their app.
|
|
437
|
+
</span>
|
|
438
|
+
</div>
|
|
439
|
+
|
|
440
|
+
<p v-if="hiddenProviderNote" class="field-hint mb-3">
|
|
441
|
+
{{ hiddenProviderNote }}
|
|
442
|
+
</p>
|
|
443
|
+
</template>
|
|
444
|
+
|
|
392
445
|
<!-- The duplicate check. It is reader-scoped: this resident holding a
|
|
393
446
|
HID user on another gate is normal and says nothing here. -->
|
|
394
447
|
<p v-if="checkingResident" class="field-hint">
|
|
@@ -412,12 +465,12 @@
|
|
|
412
465
|
whatever the reader already holds. -->
|
|
413
466
|
<!-- A mirror of the chosen member's role, on the same rule as Name:
|
|
414
467
|
restated from the record, never collected. -->
|
|
415
|
-
<template v-if="!selectedUser &&
|
|
416
|
-
<div class="field-label">
|
|
468
|
+
<template v-if="!selectedUser && isPersonSubject">
|
|
469
|
+
<div class="field-label">{{ subjectMirrorLabel }}</div>
|
|
417
470
|
<AppField
|
|
418
471
|
v-model="form.subjectRole"
|
|
419
|
-
aria-label="
|
|
420
|
-
placeholder="
|
|
472
|
+
:aria-label="subjectMirrorLabel"
|
|
473
|
+
:placeholder="subjectMirrorPlaceholder"
|
|
421
474
|
readonly
|
|
422
475
|
klass="mb-3"
|
|
423
476
|
/>
|
|
@@ -430,6 +483,8 @@
|
|
|
430
483
|
:placeholder="
|
|
431
484
|
isResidentSubject
|
|
432
485
|
? 'Select a resident above'
|
|
486
|
+
: isProviderSubject
|
|
487
|
+
? 'Select a person above'
|
|
433
488
|
: 'Select a member above'
|
|
434
489
|
"
|
|
435
490
|
readonly
|
|
@@ -567,6 +622,115 @@
|
|
|
567
622
|
</v-card>
|
|
568
623
|
</v-dialog>
|
|
569
624
|
|
|
625
|
+
<!-- ADD USERS IN BULK.
|
|
626
|
+
Over the enrolment form, not instead of it: the operator came here to
|
|
627
|
+
enrol, and this is the shortcut for the case where a whole shift arrives
|
|
628
|
+
at once. It grants ACCESS only - the people it adds are recognised by the
|
|
629
|
+
reader once they add a face, QR or PIN from their own app - which is why
|
|
630
|
+
it collects no photo, PIN or password.
|
|
631
|
+
The table is the same list as the picker behind it, under the same
|
|
632
|
+
service filter, with Company / Name / Service and nothing else: the
|
|
633
|
+
Members screen's other columns say nothing about whether to open a door
|
|
634
|
+
for somebody. -->
|
|
635
|
+
<v-dialog v-model="bulkDialog" max-width="640" persistent>
|
|
636
|
+
<v-card class="screen-modal">
|
|
637
|
+
<v-card-title>Add Users in Bulk</v-card-title>
|
|
638
|
+
<v-card-text class="screen-modal__body">
|
|
639
|
+
<p class="field-hint mb-3">
|
|
640
|
+
Gives these people access to
|
|
641
|
+
<strong>{{ bulkReaderLabel }}</strong>. They add their own face, QR
|
|
642
|
+
or PIN from their app - nothing is enrolled on the reader here.
|
|
643
|
+
</p>
|
|
644
|
+
|
|
645
|
+
<AppField
|
|
646
|
+
v-model="bulkSearch"
|
|
647
|
+
aria-label="Search people"
|
|
648
|
+
placeholder="Search by name, company or role"
|
|
649
|
+
klass="mb-3"
|
|
650
|
+
/>
|
|
651
|
+
|
|
652
|
+
<v-progress-linear v-if="bulkLoading" indeterminate class="mb-3" />
|
|
653
|
+
|
|
654
|
+
<template v-else-if="bulkRows.length">
|
|
655
|
+
<v-table density="compact" class="hid-bulk-table mb-2">
|
|
656
|
+
<thead>
|
|
657
|
+
<tr>
|
|
658
|
+
<th class="hid-bulk-table__check">
|
|
659
|
+
<!-- Acts on the rows in front of you - what the service
|
|
660
|
+
filter and the search have left - never on the whole
|
|
661
|
+
estate. -->
|
|
662
|
+
<v-checkbox
|
|
663
|
+
:model-value="bulkAllSelected"
|
|
664
|
+
:disabled="!bulkSelectableRows.length"
|
|
665
|
+
hide-details
|
|
666
|
+
density="compact"
|
|
667
|
+
aria-label="Select everyone listed"
|
|
668
|
+
@update:model-value="toggleBulkAll"
|
|
669
|
+
/>
|
|
670
|
+
</th>
|
|
671
|
+
<th>Company</th>
|
|
672
|
+
<th>Name</th>
|
|
673
|
+
<th>Service</th>
|
|
674
|
+
</tr>
|
|
675
|
+
</thead>
|
|
676
|
+
<tbody>
|
|
677
|
+
<tr v-for="row in bulkRows" :key="row.subjectId">
|
|
678
|
+
<td class="hid-bulk-table__check">
|
|
679
|
+
<!-- Already on the door: ticked and locked. Unticking here
|
|
680
|
+
would revoke access, and this dialog does not revoke. -->
|
|
681
|
+
<v-checkbox
|
|
682
|
+
:model-value="row.granted || isBulkSelected(row.subjectId)"
|
|
683
|
+
:disabled="row.granted"
|
|
684
|
+
hide-details
|
|
685
|
+
density="compact"
|
|
686
|
+
:aria-label="`Select ${row.name}`"
|
|
687
|
+
@update:model-value="toggleBulkRow(row.subjectId, $event)"
|
|
688
|
+
/>
|
|
689
|
+
</td>
|
|
690
|
+
<td>{{ row.company || "—" }}</td>
|
|
691
|
+
<td>
|
|
692
|
+
{{ row.name }}
|
|
693
|
+
<span v-if="row.granted" class="hid-bulk-granted">
|
|
694
|
+
Already has access
|
|
695
|
+
</span>
|
|
696
|
+
</td>
|
|
697
|
+
<td>{{ row.service || "—" }}</td>
|
|
698
|
+
</tr>
|
|
699
|
+
</tbody>
|
|
700
|
+
</v-table>
|
|
701
|
+
<p class="field-hint">
|
|
702
|
+
{{ bulkSelectedCount }} selected<template v-if="bulkGrantedCount">
|
|
703
|
+
· {{ bulkGrantedCount }} already have access</template
|
|
704
|
+
>
|
|
705
|
+
</p>
|
|
706
|
+
</template>
|
|
707
|
+
|
|
708
|
+
<v-alert v-else type="info" variant="tonal" density="compact">
|
|
709
|
+
{{ bulkEmptyText }}
|
|
710
|
+
</v-alert>
|
|
711
|
+
</v-card-text>
|
|
712
|
+
<v-card-actions class="screen-modal__footer">
|
|
713
|
+
<v-btn
|
|
714
|
+
class="text-none screen-btn-ghost"
|
|
715
|
+
variant="flat"
|
|
716
|
+
:disabled="bulkSaving"
|
|
717
|
+
@click="bulkDialog = false"
|
|
718
|
+
>
|
|
719
|
+
Cancel
|
|
720
|
+
</v-btn>
|
|
721
|
+
<v-btn
|
|
722
|
+
class="text-none screen-btn-primary"
|
|
723
|
+
variant="flat"
|
|
724
|
+
:loading="bulkSaving"
|
|
725
|
+
:disabled="!bulkSelectedCount"
|
|
726
|
+
@click="saveBulkAdd"
|
|
727
|
+
>
|
|
728
|
+
Give access
|
|
729
|
+
</v-btn>
|
|
730
|
+
</v-card-actions>
|
|
731
|
+
</v-card>
|
|
732
|
+
</v-dialog>
|
|
733
|
+
|
|
570
734
|
<HidCameraCaptureDialog v-model="cameraDialog" @capture="onCameraCapture" />
|
|
571
735
|
|
|
572
736
|
<v-dialog v-model="viewDialog" max-width="430">
|
|
@@ -804,9 +968,34 @@ import {
|
|
|
804
968
|
hiddenSubjectNote,
|
|
805
969
|
memberCandidateOf,
|
|
806
970
|
memberHasAccount,
|
|
971
|
+
HID_ALL_SERVICES,
|
|
972
|
+
permissionCategoryOf,
|
|
973
|
+
providerAccountsKnown,
|
|
974
|
+
providerCandidateOf,
|
|
975
|
+
providerEnrollableRows,
|
|
807
976
|
readMemberPage,
|
|
977
|
+
serviceFilterOf,
|
|
808
978
|
subjectCategoryLabel as labelForSubjectCategory,
|
|
979
|
+
subjectLinkOf,
|
|
980
|
+
type HidEnrolmentSubject,
|
|
809
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";
|
|
810
999
|
|
|
811
1000
|
const props = defineProps({
|
|
812
1001
|
site: {
|
|
@@ -847,8 +1036,13 @@ const {
|
|
|
847
1036
|
createIdentity,
|
|
848
1037
|
updateIdentity,
|
|
849
1038
|
deleteIdentity,
|
|
1039
|
+
getSitePermissions,
|
|
1040
|
+
updateSitePermissions,
|
|
850
1041
|
} = useHidAmico();
|
|
851
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();
|
|
852
1046
|
const runtimeConfig = useRuntimeConfig();
|
|
853
1047
|
|
|
854
1048
|
type HidMetadata = Record<string, unknown> & {
|
|
@@ -1034,7 +1228,11 @@ type UnitPerson = {
|
|
|
1034
1228
|
* the dialog, and `useHidReaderSelection` already set the precedent for where
|
|
1035
1229
|
* that line sits.
|
|
1036
1230
|
*/
|
|
1037
|
-
|
|
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");
|
|
1038
1236
|
|
|
1039
1237
|
const form = reactive({
|
|
1040
1238
|
reader: "",
|
|
@@ -1048,7 +1246,7 @@ const form = reactive({
|
|
|
1048
1246
|
accessPin: "",
|
|
1049
1247
|
password: "",
|
|
1050
1248
|
photoPreview: "",
|
|
1051
|
-
subjectCategory: REMEMBERED_SUBJECT_CATEGORY.value,
|
|
1249
|
+
subjectCategory: REMEMBERED_SUBJECT_CATEGORY.value as TEnrolmentSubjectValue,
|
|
1052
1250
|
subjectId: "",
|
|
1053
1251
|
/** A mirror of the chosen member's role. Never sent; display only. */
|
|
1054
1252
|
subjectRole: "",
|
|
@@ -1170,6 +1368,28 @@ const unitResidentNote = computed(() => {
|
|
|
1170
1368
|
const subjectCategoryOptions = HID_ENROLMENT_SUBJECTS;
|
|
1171
1369
|
|
|
1172
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
|
+
);
|
|
1173
1393
|
|
|
1174
1394
|
/* What an EDIT shows in place of the control, since the type cannot change.
|
|
1175
1395
|
Looked up in the util, not in the switch's own options: records carry
|
|
@@ -1363,6 +1583,387 @@ function loadMoreMembers() {
|
|
|
1363
1583
|
return fetchMemberPage(memberPage.value + 1, true);
|
|
1364
1584
|
}
|
|
1365
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
|
+
|
|
1366
1967
|
/**
|
|
1367
1968
|
* Switching type clears the chosen subject, and nothing else.
|
|
1368
1969
|
*
|
|
@@ -1372,15 +1973,20 @@ function loadMoreMembers() {
|
|
|
1372
1973
|
* choice, so they clear with it.
|
|
1373
1974
|
*/
|
|
1374
1975
|
async function onSubjectCategoryChanged() {
|
|
1375
|
-
REMEMBERED_SUBJECT_CATEGORY.value = form.subjectCategory;
|
|
1976
|
+
REMEMBERED_SUBJECT_CATEGORY.value = form.subjectCategory as HidEnrolmentSubject;
|
|
1376
1977
|
form.subjectId = "";
|
|
1377
1978
|
form.subjectRole = "";
|
|
1378
1979
|
form.name = "";
|
|
1379
1980
|
residentConflict.value = null;
|
|
1380
1981
|
|
|
1381
|
-
|
|
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) {
|
|
1382
1985
|
await loadMemberCandidates();
|
|
1383
1986
|
}
|
|
1987
|
+
if (isProviderSubject.value && !providersLoaded.value) {
|
|
1988
|
+
await loadProviderCandidates();
|
|
1989
|
+
}
|
|
1384
1990
|
}
|
|
1385
1991
|
|
|
1386
1992
|
/* The staff equivalent of `onResidentChanged`. */
|
|
@@ -1939,7 +2545,9 @@ async function openEnroll() {
|
|
|
1939
2545
|
// the request must not delay it, and the picker carries its own loading
|
|
1940
2546
|
// placeholder. Fresh each opening, because staff change between them.
|
|
1941
2547
|
resetMemberCandidates();
|
|
1942
|
-
|
|
2548
|
+
resetProviderCandidates();
|
|
2549
|
+
if (isMemberSubject.value) await loadMemberCandidates();
|
|
2550
|
+
if (isProviderSubject.value) await loadProviderCandidates();
|
|
1943
2551
|
}
|
|
1944
2552
|
|
|
1945
2553
|
async function openEdit(user: HidUser) {
|
|
@@ -1961,10 +2569,19 @@ async function openEdit(user: HidUser) {
|
|
|
1961
2569
|
removePinRequested.value = false;
|
|
1962
2570
|
form.photoPreview = "";
|
|
1963
2571
|
facialEnrolled.value = hasFacialEnrollment(user);
|
|
2572
|
+
/*
|
|
2573
|
+
* Read in the order `createIdentity` validates the links, with one extra
|
|
2574
|
+
* test. A `member` link is how BOTH a property-management member and a
|
|
2575
|
+
* service provider's own employee are stored - the person's membership at
|
|
2576
|
+
* this site - so the identity's `type` is what tells them apart. Without
|
|
2577
|
+
* that test a contractor's edit would be headed "Member".
|
|
2578
|
+
*/
|
|
1964
2579
|
form.subjectCategory = user.person
|
|
1965
2580
|
? "resident"
|
|
1966
2581
|
: user.serviceProvider
|
|
1967
2582
|
? "service_provider"
|
|
2583
|
+
: user.member && String(user.type ?? "") === "contractor"
|
|
2584
|
+
? "service_provider_staff"
|
|
1968
2585
|
: "property_management";
|
|
1969
2586
|
form.subjectId = String(
|
|
1970
2587
|
user.person || user.member || user.serviceProvider || ""
|
|
@@ -2081,6 +2698,8 @@ async function saveUser() {
|
|
|
2081
2698
|
: !selectedUser.value && !form.subjectId
|
|
2082
2699
|
? isResidentSubject.value
|
|
2083
2700
|
? "a resident"
|
|
2701
|
+
: isProviderSubject.value
|
|
2702
|
+
? "a person"
|
|
2084
2703
|
: "a member"
|
|
2085
2704
|
: !form.name
|
|
2086
2705
|
? "a name"
|
|
@@ -2139,27 +2758,7 @@ async function saveUser() {
|
|
|
2139
2758
|
// an empty string as a link being removed and then refuses the write for
|
|
2140
2759
|
// having no subject at all. Omitting the keys is what makes it keep the
|
|
2141
2760
|
// subject the record already has.
|
|
2142
|
-
const subjectLink = form.subjectId
|
|
2143
|
-
? {
|
|
2144
|
-
person: form.subjectCategory === "resident" ? form.subjectId : "",
|
|
2145
|
-
member:
|
|
2146
|
-
form.subjectCategory === "property_management"
|
|
2147
|
-
? form.subjectId
|
|
2148
|
-
: "",
|
|
2149
|
-
serviceProvider:
|
|
2150
|
-
form.subjectCategory === "service_provider" ? form.subjectId : "",
|
|
2151
|
-
type: (form.subjectCategory === "resident"
|
|
2152
|
-
? "resident"
|
|
2153
|
-
: form.subjectCategory === "service_provider"
|
|
2154
|
-
? "contractor"
|
|
2155
|
-
: "staff") as
|
|
2156
|
-
| "resident"
|
|
2157
|
-
| "staff"
|
|
2158
|
-
| "contractor"
|
|
2159
|
-
| "visitor"
|
|
2160
|
-
| "unknown",
|
|
2161
|
-
}
|
|
2162
|
-
: {};
|
|
2761
|
+
const subjectLink = subjectLinkOf(form.subjectCategory, form.subjectId);
|
|
2163
2762
|
|
|
2164
2763
|
const payload = {
|
|
2165
2764
|
hidUserId: String(hidUser.id),
|
|
@@ -3283,6 +3882,47 @@ watch(selectedReaderId, (value) => {
|
|
|
3283
3882
|
color: var(--err);
|
|
3284
3883
|
}
|
|
3285
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
|
+
|
|
3286
3926
|
.photo-wrap {
|
|
3287
3927
|
display: grid;
|
|
3288
3928
|
place-items: center;
|
|
@@ -19,10 +19,15 @@ export type TSiteProviderMember = {
|
|
|
19
19
|
unit?: string | null;
|
|
20
20
|
unitName?: string;
|
|
21
21
|
/**
|
|
22
|
-
* The person's user account.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* The person's user account. Anything acting on the PERSON needs this rather
|
|
23
|
+
* than `_id`, which identifies the membership: an access card carries it as
|
|
24
|
+
* `userId`, and HID reader access is bound by it -
|
|
25
|
+
* `resolvePermissionUserBindings` drops every subject without one.
|
|
26
|
+
*
|
|
27
|
+
* Returned from core 3.130. Before that the repository projected it and the
|
|
28
|
+
* service layer dropped it, so against an older API it is absent on every
|
|
29
|
+
* row - `providerAccountsKnown` is how the HID enrolment screen tells the two
|
|
30
|
+
* apart rather than reading an absence as "nobody has an account".
|
|
26
31
|
*/
|
|
27
32
|
user?: string | null;
|
|
28
33
|
};
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "4.82.1-staging.
|
|
5
|
+
"version": "4.82.1-staging.480",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
|
package/types/site.d.ts
CHANGED
|
@@ -5,7 +5,10 @@ declare type THidQrCodeFormat = "0" | "1" | "2";
|
|
|
5
5
|
declare type THidPermissionCategory =
|
|
6
6
|
| "resident"
|
|
7
7
|
| "property_management"
|
|
8
|
-
|
|
8
|
+
/** A provider COMPANY - granting one admits every active member of that org. */
|
|
9
|
+
| "service_provider"
|
|
10
|
+
/** ONE PERSON from a provider company, by their membership at this site. */
|
|
11
|
+
| "service_provider_member";
|
|
9
12
|
|
|
10
13
|
declare type THidPermissionAssignment = {
|
|
11
14
|
subjectId: string;
|
|
@@ -27,7 +30,10 @@ declare type THidSitePermissions = {
|
|
|
27
30
|
counts: {
|
|
28
31
|
resident: number;
|
|
29
32
|
propertyManagement: number;
|
|
33
|
+
/** Provider COMPANIES granted. */
|
|
30
34
|
serviceProvider: number;
|
|
35
|
+
/** Named provider people granted. Counted apart from the companies. */
|
|
36
|
+
serviceProviderMember: number;
|
|
31
37
|
intercom: number;
|
|
32
38
|
};
|
|
33
39
|
};
|
|
@@ -8,14 +8,20 @@
|
|
|
8
8
|
* in this repo.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* What the ENROLLING dropdown offers. Three of these four are the server's own
|
|
13
|
+
* permission categories; `service_provider_staff` is this screen's own value
|
|
14
|
+
* and deliberately not one of them - see `subjectLinkOf`.
|
|
15
|
+
*/
|
|
16
|
+
export type HidEnrolmentSubject =
|
|
17
|
+
| "resident"
|
|
18
|
+
| "property_management"
|
|
19
|
+
| "service_provider_staff";
|
|
12
20
|
|
|
13
21
|
/**
|
|
14
22
|
* The dropdown's options, in the order they are drawn.
|
|
15
23
|
*
|
|
16
|
-
* `title`/`value` because `AppSelect` takes that shape.
|
|
17
|
-
* option is expected next; adding it here is most of the work, since the write
|
|
18
|
-
* path already maps `service_provider` onto `serviceProvider`.
|
|
24
|
+
* `title`/`value` because `AppSelect` takes that shape.
|
|
19
25
|
*/
|
|
20
26
|
export const HID_ENROLMENT_SUBJECTS: ReadonlyArray<{
|
|
21
27
|
title: string;
|
|
@@ -23,6 +29,7 @@ export const HID_ENROLMENT_SUBJECTS: ReadonlyArray<{
|
|
|
23
29
|
}> = [
|
|
24
30
|
{ title: "Resident", value: "resident" },
|
|
25
31
|
{ title: "Member", value: "property_management" },
|
|
32
|
+
{ title: "Service provider", value: "service_provider_staff" },
|
|
26
33
|
];
|
|
27
34
|
|
|
28
35
|
/**
|
|
@@ -38,6 +45,10 @@ export function subjectCategoryLabel(category: unknown): string {
|
|
|
38
45
|
const labels: Record<string, string> = {
|
|
39
46
|
resident: "Resident",
|
|
40
47
|
property_management: "Member",
|
|
48
|
+
// The dropdown's own value: ONE PERSON from a provider company.
|
|
49
|
+
service_provider_staff: "Service provider",
|
|
50
|
+
// The server's category, which names a COMPANY. Records in the wild carry
|
|
51
|
+
// it, and an edit must not call such a row a resident.
|
|
41
52
|
service_provider: "Service provider",
|
|
42
53
|
visitor: "Visitor",
|
|
43
54
|
};
|
|
@@ -142,3 +153,192 @@ export function hiddenSubjectNote(count: number, noun: "resident" | "member"): s
|
|
|
142
153
|
return `${subject} at this site ${verb} not listed:`
|
|
143
154
|
+ ` HID enrollment needs an app account, and ${holder} none yet.`;
|
|
144
155
|
}
|
|
156
|
+
|
|
157
|
+
/* ── THE LINK THAT REACHES THE DEVICE ──────────────────────────────────── */
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* WHICH SUBJECT FIELD AN ENROLMENT WRITES, and what the identity is called.
|
|
161
|
+
*
|
|
162
|
+
* This is the one decision in the dialog that changes who a door lets in, so it
|
|
163
|
+
* lives here with tests rather than inline in the template's script.
|
|
164
|
+
*
|
|
165
|
+
* `service_provider_staff` links through `member`, NOT `serviceProvider`, and
|
|
166
|
+
* that is the whole design:
|
|
167
|
+
*
|
|
168
|
+
* - `serviceProvider` names a `site.service-providers` row, which is a COMPANY.
|
|
169
|
+
* `resolvePermissionUserBindings` expands one into every active member of
|
|
170
|
+
* that organisation with NO site filter, so granting it puts a 200-person
|
|
171
|
+
* firm on a door that eight of them attend.
|
|
172
|
+
* - A provider employee's membership lives in `members` with `siteId` set to
|
|
173
|
+
* this site - `members` carries a unique index on `{org, siteId, user, type}`,
|
|
174
|
+
* so it cannot exist there without one. That makes the person site-scoped by
|
|
175
|
+
* construction, which is the whole point.
|
|
176
|
+
*
|
|
177
|
+
* The identity `type` is `"contractor"` rather than `"staff"`, and it is load
|
|
178
|
+
* bearing on the server as well as here: core's `permissionSubjectOfIdentity`
|
|
179
|
+
* reads it to tell a contractor's `member` link from a property-management one,
|
|
180
|
+
* and files the grant under `service_provider_member` or `property_management`
|
|
181
|
+
* accordingly. It is also what the Access Permissions tabs filter on.
|
|
182
|
+
*
|
|
183
|
+
* An earlier version filed provider staff under `property_management`, since that
|
|
184
|
+
* category's subject list already accepts these rows. It worked for access and
|
|
185
|
+
* failed for identity: `permissionIdentityType` derives the identity's type from
|
|
186
|
+
* the category, so every contractor came back as `staff` - and because the
|
|
187
|
+
* reconcile rewrites the identity it finds, the `"contractor"` written here was
|
|
188
|
+
* overwritten seconds later.
|
|
189
|
+
*
|
|
190
|
+
* An empty `subjectId` returns `{}` ON PURPOSE. Sending `person: ""` is not
|
|
191
|
+
* "leave this alone", it is "clear it": the server reads an empty string as the
|
|
192
|
+
* link being removed and then refuses the write for having no subject at all.
|
|
193
|
+
*/
|
|
194
|
+
export function subjectLinkOf(
|
|
195
|
+
category: unknown,
|
|
196
|
+
subjectId: unknown,
|
|
197
|
+
): {
|
|
198
|
+
person?: string;
|
|
199
|
+
member?: string;
|
|
200
|
+
serviceProvider?: string;
|
|
201
|
+
type?: "resident" | "staff" | "contractor";
|
|
202
|
+
} {
|
|
203
|
+
const id = String(subjectId ?? "").trim();
|
|
204
|
+
if (!id) return {};
|
|
205
|
+
const of = (
|
|
206
|
+
field: "person" | "member" | "serviceProvider",
|
|
207
|
+
type: "resident" | "staff" | "contractor",
|
|
208
|
+
) => ({
|
|
209
|
+
person: field === "person" ? id : "",
|
|
210
|
+
member: field === "member" ? id : "",
|
|
211
|
+
serviceProvider: field === "serviceProvider" ? id : "",
|
|
212
|
+
type,
|
|
213
|
+
});
|
|
214
|
+
switch (String(category ?? "")) {
|
|
215
|
+
case "resident":
|
|
216
|
+
return of("person", "resident");
|
|
217
|
+
case "service_provider_staff":
|
|
218
|
+
return of("member", "contractor");
|
|
219
|
+
case "service_provider":
|
|
220
|
+
return of("serviceProvider", "contractor");
|
|
221
|
+
default:
|
|
222
|
+
return of("member", "staff");
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Which PERMISSION CATEGORY an enrolment's access grant is stored under.
|
|
228
|
+
*
|
|
229
|
+
* Mirrors `permissionSubjectOfIdentity` in core, which reads the stored links
|
|
230
|
+
* rather than this dropdown: a `member` link resolves to `property_management`
|
|
231
|
+
* whatever the identity's `type` says. Provider staff therefore share the staff
|
|
232
|
+
* category, which is correct - they are validated against the same subject list
|
|
233
|
+
* - and the identity `type` is what tells them apart on screen.
|
|
234
|
+
*/
|
|
235
|
+
export function permissionCategoryOf(category: unknown): string {
|
|
236
|
+
const value = String(category ?? "");
|
|
237
|
+
if (value === "resident") return "resident";
|
|
238
|
+
if (value === "service_provider") return "service_provider";
|
|
239
|
+
if (value === "service_provider_staff") return "service_provider_member";
|
|
240
|
+
return "property_management";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* ── PROVIDER STAFF AS PICKER ROWS ─────────────────────────────────────── */
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Can this provider employee be enrolled?
|
|
247
|
+
*
|
|
248
|
+
* Two conditions, and both have to be checked here rather than trusted from the
|
|
249
|
+
* list. An app account, because reader access is bound by user id
|
|
250
|
+
* (`memberHasAccount`). And an ACTIVE membership: `getProviderMembersBySite`
|
|
251
|
+
* returns everyone who is not deleted, so a suspended person is in the list the
|
|
252
|
+
* Members screen draws - marked, deliberately, so the property manager can see
|
|
253
|
+
* them. Offering a suspended person a door is a different matter.
|
|
254
|
+
*/
|
|
255
|
+
export function providerCanEnrol(
|
|
256
|
+
row: Record<string, unknown> | null | undefined,
|
|
257
|
+
): boolean {
|
|
258
|
+
if (!memberHasAccount(row)) return false;
|
|
259
|
+
return String(row?.status ?? "").trim() === "active";
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* How a provider employee reads in the picker: their name, their company and
|
|
264
|
+
* the service beneath it.
|
|
265
|
+
*
|
|
266
|
+
* The company comes FIRST in the subtitle. Two contractors with the same first
|
|
267
|
+
* name are common, and which firm somebody belongs to is what the property
|
|
268
|
+
* manager actually recognises them by - the service type alone ("Security")
|
|
269
|
+
* describes half the list.
|
|
270
|
+
*/
|
|
271
|
+
export function providerCandidateOf(row: Record<string, unknown>): {
|
|
272
|
+
subjectId: string;
|
|
273
|
+
name: string;
|
|
274
|
+
subtitle: string;
|
|
275
|
+
} {
|
|
276
|
+
const text = (value: unknown) => String(value ?? "").trim();
|
|
277
|
+
const parts = [text(row.company), text(row.typeLabel)].filter(Boolean);
|
|
278
|
+
return {
|
|
279
|
+
subjectId: text(row._id),
|
|
280
|
+
name: text(row.name) || text(row.email) || "Service provider",
|
|
281
|
+
subtitle: parts.join(" · ") || text(row.role) || text(row.email) || "",
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Does this list tell us who has an app account?
|
|
287
|
+
*
|
|
288
|
+
* `GET /api/service-providers/site-members` only began returning `user` in core
|
|
289
|
+
* 3.130; before that the service layer dropped it even though the repository
|
|
290
|
+
* projected it. Frontend and backend deploy separately, so this screen can run
|
|
291
|
+
* against either. Asked of the ROWS rather than of a version number: the key is
|
|
292
|
+
* present on every row or on none.
|
|
293
|
+
*
|
|
294
|
+
* It matters because the answer flips a filter from "cannot be bound to a door"
|
|
295
|
+
* to "unknown", and a screen that silently reports NOBODY can be enrolled is
|
|
296
|
+
* worse than one that offers somebody the server then refuses by name.
|
|
297
|
+
*/
|
|
298
|
+
export function providerAccountsKnown(
|
|
299
|
+
rows: ReadonlyArray<Record<string, unknown>>,
|
|
300
|
+
): boolean {
|
|
301
|
+
return rows.some((row) => Boolean(row) && typeof row === "object" && "user" in row);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Who may be offered a door, from whatever the endpoint returned.
|
|
306
|
+
*
|
|
307
|
+
* With accounts known, both conditions apply (`providerCanEnrol`). Without them,
|
|
308
|
+
* active membership is all that can be checked here — the server still refuses
|
|
309
|
+
* anyone it cannot bind, with a message naming them, which is a better failure
|
|
310
|
+
* than an empty picker.
|
|
311
|
+
*/
|
|
312
|
+
export function providerEnrollableRows<T extends Record<string, unknown>>(
|
|
313
|
+
rows: ReadonlyArray<T>,
|
|
314
|
+
): T[] {
|
|
315
|
+
const known = providerAccountsKnown(rows);
|
|
316
|
+
return rows.filter((row) =>
|
|
317
|
+
known ? providerCanEnrol(row) : String(row?.status ?? "").trim() === "active",
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/* ── THE SERVICE FILTER'S "ALL" ────────────────────────────────────────── */
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* WHY "ALL SERVICES" CANNOT BE THE EMPTY STRING HERE.
|
|
325
|
+
*
|
|
326
|
+
* `filterProviderMembers` reads an empty `type` as "do not filter", and the
|
|
327
|
+
* Members screen's own filter uses `value: ""` for All services - but that screen
|
|
328
|
+
* draws a Vuetify `v-select`, which renders an empty value perfectly well.
|
|
329
|
+
*
|
|
330
|
+
* `AppSelect` does not. Its `hasSingleValue` is false for `""`, so the field
|
|
331
|
+
* falls back to the placeholder, draws in the placeholder's grey, and the option
|
|
332
|
+
* is never marked as the chosen one in the menu. The filter worked; it just
|
|
333
|
+
* looked like nothing was selected and like only the real services existed.
|
|
334
|
+
*
|
|
335
|
+
* So the option carries a real value here and is translated on the way to the
|
|
336
|
+
* filter. Changing `AppSelect` instead would touch every one of its callers.
|
|
337
|
+
*/
|
|
338
|
+
export const HID_ALL_SERVICES = "all";
|
|
339
|
+
|
|
340
|
+
/** The `type` to filter by: "" for All services, the service code otherwise. */
|
|
341
|
+
export function serviceFilterOf(selected: unknown): string {
|
|
342
|
+
const value = String(selected ?? "").trim();
|
|
343
|
+
return value === HID_ALL_SERVICES ? "" : value;
|
|
344
|
+
}
|
|
@@ -27,11 +27,19 @@
|
|
|
27
27
|
* through is a 400.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* The real categories. `intercom` is a candidate FILTER, never a category.
|
|
32
|
+
*
|
|
33
|
+
* Mirrors `HID_PERMISSION_CATEGORIES` in core, and has to: `seedAssignments`
|
|
34
|
+
* DROPS a row whose category is not in this list, so a category missing here
|
|
35
|
+
* silently loses that grant from the edit state - and the save that follows,
|
|
36
|
+
* being a whole-reader replace, would revoke it.
|
|
37
|
+
*/
|
|
31
38
|
export const HID_PERMISSION_CATEGORIES: THidPermissionCategory[] = [
|
|
32
39
|
"resident",
|
|
33
40
|
"property_management",
|
|
34
41
|
"service_provider",
|
|
42
|
+
"service_provider_member",
|
|
35
43
|
];
|
|
36
44
|
|
|
37
45
|
export type HidAssignmentState = Map<string, { category: THidPermissionCategory; intercom: boolean }>;
|