@7365admin1/layer-common 4.97.1-staging.503 → 4.98.0
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 +885 -4
- package/components/AccessCardDetailsDialog.vue +2 -2
- package/components/AccessCardPreviewDialog.vue +9 -86
- package/components/HidAccessPermissions.vue +38 -146
- package/components/HidUserEnrollment.vue +39 -679
- package/composables/useAccessManagement.ts +1 -17
- package/composables/useServiceProvider.ts +4 -9
- package/package.json +1 -1
- package/types/site.d.ts +1 -7
- package/utils/hid-enrolment-subject.ts +4 -226
- package/utils/hid-permission-assignments.ts +6 -75
|
@@ -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 && !isResidentSubject">
|
|
367
367
|
<div class="field-label">Member <span>*</span></div>
|
|
368
368
|
<AppSelect
|
|
369
369
|
v-model="form.subjectId"
|
|
@@ -389,59 +389,6 @@
|
|
|
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
|
-
|
|
445
392
|
<!-- The duplicate check. It is reader-scoped: this resident holding a
|
|
446
393
|
HID user on another gate is normal and says nothing here. -->
|
|
447
394
|
<p v-if="checkingResident" class="field-hint">
|
|
@@ -465,12 +412,12 @@
|
|
|
465
412
|
whatever the reader already holds. -->
|
|
466
413
|
<!-- A mirror of the chosen member's role, on the same rule as Name:
|
|
467
414
|
restated from the record, never collected. -->
|
|
468
|
-
<template v-if="!selectedUser &&
|
|
469
|
-
<div class="field-label">
|
|
415
|
+
<template v-if="!selectedUser && !isResidentSubject">
|
|
416
|
+
<div class="field-label">Role</div>
|
|
470
417
|
<AppField
|
|
471
418
|
v-model="form.subjectRole"
|
|
472
|
-
|
|
473
|
-
|
|
419
|
+
aria-label="Role"
|
|
420
|
+
placeholder="Select a member above"
|
|
474
421
|
readonly
|
|
475
422
|
klass="mb-3"
|
|
476
423
|
/>
|
|
@@ -483,8 +430,6 @@
|
|
|
483
430
|
:placeholder="
|
|
484
431
|
isResidentSubject
|
|
485
432
|
? 'Select a resident above'
|
|
486
|
-
: isProviderSubject
|
|
487
|
-
? 'Select a person above'
|
|
488
433
|
: 'Select a member above'
|
|
489
434
|
"
|
|
490
435
|
readonly
|
|
@@ -622,115 +567,6 @@
|
|
|
622
567
|
</v-card>
|
|
623
568
|
</v-dialog>
|
|
624
569
|
|
|
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
570
|
<HidCameraCaptureDialog v-model="cameraDialog" @capture="onCameraCapture" />
|
|
735
571
|
|
|
736
572
|
<v-dialog v-model="viewDialog" max-width="430">
|
|
@@ -968,36 +804,9 @@ import {
|
|
|
968
804
|
hiddenSubjectNote,
|
|
969
805
|
memberCandidateOf,
|
|
970
806
|
memberHasAccount,
|
|
971
|
-
HID_ALL_SERVICES,
|
|
972
|
-
enrolmentSubjectOfCategory,
|
|
973
|
-
permissionCategoryOf,
|
|
974
|
-
providerAccountsKnown,
|
|
975
|
-
providerCandidateOf,
|
|
976
|
-
providerEnrollableRows,
|
|
977
807
|
readMemberPage,
|
|
978
|
-
serviceFilterOf,
|
|
979
808
|
subjectCategoryLabel as labelForSubjectCategory,
|
|
980
|
-
subjectLinkOf,
|
|
981
|
-
type HidEnrolmentSubject,
|
|
982
809
|
} from "../utils/hid-enrolment-subject";
|
|
983
|
-
/* The SAME filter the Members > Service Providers screen applies, not a second
|
|
984
|
-
copy of the rule: a picker that disagrees with the screen the operator was
|
|
985
|
-
just looking at is a picker they cannot trust. Core applies it to the Excel
|
|
986
|
-
export too (`filterSiteProviderMembers`). */
|
|
987
|
-
import {
|
|
988
|
-
filterProviderMembers,
|
|
989
|
-
serviceTypeOptions,
|
|
990
|
-
} from "../utils/site-provider-members";
|
|
991
|
-
/* Bulk access is granted through the permissions document, and the PUT REPLACES
|
|
992
|
-
this reader's whole assignment set. These three are what keep a bulk add from
|
|
993
|
-
revoking everybody who was already on the door - see the file's own header. */
|
|
994
|
-
import {
|
|
995
|
-
seedAssignments,
|
|
996
|
-
setAssignment,
|
|
997
|
-
subjectOfIdentity,
|
|
998
|
-
toAssignmentPayload,
|
|
999
|
-
type HidAssignmentState,
|
|
1000
|
-
} from "../utils/hid-permission-assignments";
|
|
1001
810
|
|
|
1002
811
|
const props = defineProps({
|
|
1003
812
|
site: {
|
|
@@ -1038,13 +847,8 @@ const {
|
|
|
1038
847
|
createIdentity,
|
|
1039
848
|
updateIdentity,
|
|
1040
849
|
deleteIdentity,
|
|
1041
|
-
getSitePermissions,
|
|
1042
|
-
updateSitePermissions,
|
|
1043
850
|
} = useHidAmico();
|
|
1044
851
|
const { getAll: getAllMembers } = useMember();
|
|
1045
|
-
/* Members > Service Providers reads this same call, so the enrolment picker
|
|
1046
|
-
offers exactly the people that screen lists. Site-scoped by the server. */
|
|
1047
|
-
const { getSiteMembers } = useServiceProvider();
|
|
1048
852
|
const runtimeConfig = useRuntimeConfig();
|
|
1049
853
|
|
|
1050
854
|
type HidMetadata = Record<string, unknown> & {
|
|
@@ -1230,11 +1034,7 @@ type UnitPerson = {
|
|
|
1230
1034
|
* the dialog, and `useHidReaderSelection` already set the precedent for where
|
|
1231
1035
|
* that line sits.
|
|
1232
1036
|
*/
|
|
1233
|
-
|
|
1234
|
-
offer but `openEdit` resolves from records that name a COMPANY. */
|
|
1235
|
-
type TEnrolmentSubjectValue = HidEnrolmentSubject | "service_provider";
|
|
1236
|
-
|
|
1237
|
-
const REMEMBERED_SUBJECT_CATEGORY = ref<HidEnrolmentSubject>("resident");
|
|
1037
|
+
const REMEMBERED_SUBJECT_CATEGORY = ref<THidPermissionCategory>("resident");
|
|
1238
1038
|
|
|
1239
1039
|
const form = reactive({
|
|
1240
1040
|
reader: "",
|
|
@@ -1248,7 +1048,7 @@ const form = reactive({
|
|
|
1248
1048
|
accessPin: "",
|
|
1249
1049
|
password: "",
|
|
1250
1050
|
photoPreview: "",
|
|
1251
|
-
subjectCategory: REMEMBERED_SUBJECT_CATEGORY.value
|
|
1051
|
+
subjectCategory: REMEMBERED_SUBJECT_CATEGORY.value,
|
|
1252
1052
|
subjectId: "",
|
|
1253
1053
|
/** A mirror of the chosen member's role. Never sent; display only. */
|
|
1254
1054
|
subjectRole: "",
|
|
@@ -1370,28 +1170,6 @@ const unitResidentNote = computed(() => {
|
|
|
1370
1170
|
const subjectCategoryOptions = HID_ENROLMENT_SUBJECTS;
|
|
1371
1171
|
|
|
1372
1172
|
const isResidentSubject = computed(() => form.subjectCategory === "resident");
|
|
1373
|
-
/* Members and provider staff both pick ONE PERSON from a flat list, but from
|
|
1374
|
-
different lists, so they are separate branches rather than "not a resident". */
|
|
1375
|
-
const isMemberSubject = computed(
|
|
1376
|
-
() => form.subjectCategory === "property_management"
|
|
1377
|
-
);
|
|
1378
|
-
const isProviderSubject = computed(
|
|
1379
|
-
() => form.subjectCategory === "service_provider_staff"
|
|
1380
|
-
);
|
|
1381
|
-
/** Either of the two person pickers - what the Role mirror follows. */
|
|
1382
|
-
const isPersonSubject = computed(
|
|
1383
|
-
() => isMemberSubject.value || isProviderSubject.value
|
|
1384
|
-
);
|
|
1385
|
-
|
|
1386
|
-
/* The mirror's label follows the list it mirrors. A member's subtitle is their
|
|
1387
|
-
role at the site; a contractor's is the firm they work for, and calling that
|
|
1388
|
-
"Role" would read as a mistake. */
|
|
1389
|
-
const subjectMirrorLabel = computed(() =>
|
|
1390
|
-
isProviderSubject.value ? "Company" : "Role"
|
|
1391
|
-
);
|
|
1392
|
-
const subjectMirrorPlaceholder = computed(() =>
|
|
1393
|
-
isProviderSubject.value ? "Select a person above" : "Select a member above"
|
|
1394
|
-
);
|
|
1395
1173
|
|
|
1396
1174
|
/* What an EDIT shows in place of the control, since the type cannot change.
|
|
1397
1175
|
Looked up in the util, not in the switch's own options: records carry
|
|
@@ -1585,387 +1363,6 @@ function loadMoreMembers() {
|
|
|
1585
1363
|
return fetchMemberPage(memberPage.value + 1, true);
|
|
1586
1364
|
}
|
|
1587
1365
|
|
|
1588
|
-
/* ── SERVICE PROVIDER STAFF ───────────────────────────────────────────────
|
|
1589
|
-
*
|
|
1590
|
-
* ONE PERSON from a provider company, never the company.
|
|
1591
|
-
*
|
|
1592
|
-
* Read from `GET /api/service-providers/site-members` - the same call behind
|
|
1593
|
-
* Members > Service Providers, with the same `filterProviderMembers` rule - so
|
|
1594
|
-
* the picker lists exactly the people that screen lists. The server scopes it to
|
|
1595
|
-
* the site; these are the contractors who attend THIS site, not every employee
|
|
1596
|
-
* of every firm engaged with the organisation.
|
|
1597
|
-
*
|
|
1598
|
-
* Unpaged, deliberately, unlike the staff picker above: this endpoint returns
|
|
1599
|
-
* the site's people in one response (capped at 1000 by the repository) and has
|
|
1600
|
-
* no per-page cache entry to disagree with. The service filter and the search
|
|
1601
|
-
* box both run over the loaded list, so neither costs a request.
|
|
1602
|
-
*/
|
|
1603
|
-
const providerPeople = ref<Record<string, unknown>[]>([]);
|
|
1604
|
-
const providerCompanyCount = ref(0);
|
|
1605
|
-
const loadingProviders = ref(false);
|
|
1606
|
-
const providersLoaded = ref(false);
|
|
1607
|
-
/**
|
|
1608
|
-
* All services is the default, the same as the Members screen - but it carries a
|
|
1609
|
-
* REAL value here, not "". `AppSelect`'s `hasSingleValue` is false for an empty
|
|
1610
|
-
* string, so an empty-valued option draws the placeholder in placeholder grey and
|
|
1611
|
-
* is never marked as chosen: the filter worked and the field looked blank. See
|
|
1612
|
-
* `serviceFilterOf`.
|
|
1613
|
-
*/
|
|
1614
|
-
const providerService = ref<string>(HID_ALL_SERVICES);
|
|
1615
|
-
|
|
1616
|
-
/** What the filter actually receives: "" for All services. */
|
|
1617
|
-
const providerServiceFilter = computed(() => serviceFilterOf(providerService.value));
|
|
1618
|
-
let providerRequestSeq = 0;
|
|
1619
|
-
|
|
1620
|
-
/** Everyone who can actually be given a door: an app account, and active. */
|
|
1621
|
-
const providerEnrollable = computed(() =>
|
|
1622
|
-
providerEnrollableRows(providerPeople.value)
|
|
1623
|
-
);
|
|
1624
|
-
|
|
1625
|
-
/* Whether the endpoint told us who holds an app account. It only began
|
|
1626
|
-
returning `user` in core 3.130, and this layer can be deployed ahead of it. */
|
|
1627
|
-
const providerAccountsVisible = computed(() =>
|
|
1628
|
-
providerAccountsKnown(providerPeople.value)
|
|
1629
|
-
);
|
|
1630
|
-
|
|
1631
|
-
/** People the operator can see on the Members screen but cannot enrol here. */
|
|
1632
|
-
const hiddenProviderCount = computed(
|
|
1633
|
-
() => providerPeople.value.length - providerEnrollable.value.length
|
|
1634
|
-
);
|
|
1635
|
-
|
|
1636
|
-
const providerServiceOptions = computed(() => [
|
|
1637
|
-
{ title: "All services", value: HID_ALL_SERVICES },
|
|
1638
|
-
// From the ENROLLABLE set, so choosing a service never lands on an empty list.
|
|
1639
|
-
...serviceTypeOptions(providerEnrollable.value as Array<{ type?: string; typeLabel?: string }>),
|
|
1640
|
-
]);
|
|
1641
|
-
|
|
1642
|
-
/** The filtered list - what both the picker and the bulk table draw. */
|
|
1643
|
-
const providerRows = computed(() =>
|
|
1644
|
-
filterProviderMembers(providerEnrollable.value as Array<Record<string, any>>, {
|
|
1645
|
-
type: providerServiceFilter.value,
|
|
1646
|
-
})
|
|
1647
|
-
);
|
|
1648
|
-
|
|
1649
|
-
const providerOptions = computed(() =>
|
|
1650
|
-
providerRows.value.map((row) => {
|
|
1651
|
-
const candidate = providerCandidateOf(row);
|
|
1652
|
-
return {
|
|
1653
|
-
// The firm in the title as well as the mirror: two contractors sharing a
|
|
1654
|
-
// first name is the normal case, and the popover is where they have to be
|
|
1655
|
-
// told apart.
|
|
1656
|
-
title: candidate.subtitle
|
|
1657
|
-
? `${candidate.name} — ${candidate.subtitle}`
|
|
1658
|
-
: candidate.name,
|
|
1659
|
-
value: candidate.subjectId,
|
|
1660
|
-
};
|
|
1661
|
-
})
|
|
1662
|
-
);
|
|
1663
|
-
|
|
1664
|
-
const providerPlaceholder = computed(() => {
|
|
1665
|
-
if (loadingProviders.value) return "Loading people...";
|
|
1666
|
-
if (!providersLoaded.value) return "Select a person";
|
|
1667
|
-
if (providerOptions.value.length) return "Select a person";
|
|
1668
|
-
if (providerServiceFilter.value) return "Nobody in this service can be enrolled yet";
|
|
1669
|
-
if (hiddenProviderCount.value) return "Nobody here can be enrolled yet";
|
|
1670
|
-
return providerCompanyCount.value
|
|
1671
|
-
? "No service provider people at this site"
|
|
1672
|
-
: "No service providers work at this site yet";
|
|
1673
|
-
});
|
|
1674
|
-
|
|
1675
|
-
/* Say why somebody on the Members screen is not in this list, rather than
|
|
1676
|
-
letting a missing colleague read as a broken dialog. */
|
|
1677
|
-
const hiddenProviderNote = computed(() => {
|
|
1678
|
-
if (loadingProviders.value || !hiddenProviderCount.value) return "";
|
|
1679
|
-
const count = hiddenProviderCount.value;
|
|
1680
|
-
const subject = count === 1 ? "1 person" : `${count} people`;
|
|
1681
|
-
const verb = count === 1 ? "is" : "are";
|
|
1682
|
-
const holder = count === 1 ? "that person is" : "they are";
|
|
1683
|
-
const needs = providerAccountsVisible.value
|
|
1684
|
-
? "an active membership with an app account"
|
|
1685
|
-
: "an active membership at this site";
|
|
1686
|
-
return `${subject} from this site's service providers ${verb} not listed:`
|
|
1687
|
-
+ ` HID enrollment needs ${needs}, and ${holder} without one.`;
|
|
1688
|
-
});
|
|
1689
|
-
|
|
1690
|
-
/** Back to "nothing loaded", so the next open starts clean. */
|
|
1691
|
-
function resetProviderCandidates() {
|
|
1692
|
-
providerRequestSeq += 1;
|
|
1693
|
-
providerPeople.value = [];
|
|
1694
|
-
providerCompanyCount.value = 0;
|
|
1695
|
-
providerService.value = HID_ALL_SERVICES;
|
|
1696
|
-
providersLoaded.value = false;
|
|
1697
|
-
}
|
|
1698
|
-
|
|
1699
|
-
/**
|
|
1700
|
-
* This site's service provider people.
|
|
1701
|
-
*
|
|
1702
|
-
* NEVER THROWS, on the same rule as the staff loader: choosing Service provider
|
|
1703
|
-
* must not break the dialog. The placeholder carries the failure.
|
|
1704
|
-
*/
|
|
1705
|
-
async function loadProviderCandidates() {
|
|
1706
|
-
if (!props.site) {
|
|
1707
|
-
resetProviderCandidates();
|
|
1708
|
-
providersLoaded.value = true;
|
|
1709
|
-
return;
|
|
1710
|
-
}
|
|
1711
|
-
const seq = (providerRequestSeq += 1);
|
|
1712
|
-
loadingProviders.value = true;
|
|
1713
|
-
try {
|
|
1714
|
-
const data = await getSiteMembers(props.site);
|
|
1715
|
-
// A response that arrived after the dialog was reopened belongs to a list
|
|
1716
|
-
// that no longer exists - the same guard the staff pager uses.
|
|
1717
|
-
if (seq !== providerRequestSeq) return;
|
|
1718
|
-
providerPeople.value = Array.isArray(data?.items)
|
|
1719
|
-
? (data.items as unknown as Record<string, unknown>[])
|
|
1720
|
-
: [];
|
|
1721
|
-
providerCompanyCount.value = Array.isArray(data?.companies)
|
|
1722
|
-
? data.companies.length
|
|
1723
|
-
: 0;
|
|
1724
|
-
providersLoaded.value = true;
|
|
1725
|
-
} catch (error: unknown) {
|
|
1726
|
-
if (seq !== providerRequestSeq) return;
|
|
1727
|
-
console.error("Unable to load service provider people for HID enrollment:", error);
|
|
1728
|
-
providerPeople.value = [];
|
|
1729
|
-
providerCompanyCount.value = 0;
|
|
1730
|
-
providersLoaded.value = true;
|
|
1731
|
-
} finally {
|
|
1732
|
-
loadingProviders.value = false;
|
|
1733
|
-
}
|
|
1734
|
-
}
|
|
1735
|
-
|
|
1736
|
-
/** The chosen person, restated into Name and the Company mirror. */
|
|
1737
|
-
function onProviderChanged() {
|
|
1738
|
-
// Compared on the TRIMMED id, because that is what the option's value carries
|
|
1739
|
-
// (`providerCandidateOf` trims) - matching the raw field would silently find
|
|
1740
|
-
// nobody and blank the name the operator just picked.
|
|
1741
|
-
const row = providerRows.value.find(
|
|
1742
|
-
(item) => String(item._id ?? "").trim() === form.subjectId
|
|
1743
|
-
);
|
|
1744
|
-
const candidate = row ? providerCandidateOf(row) : null;
|
|
1745
|
-
form.name = candidate?.name ?? "";
|
|
1746
|
-
form.subjectRole = candidate?.subtitle ?? "";
|
|
1747
|
-
void checkSubjectEnrollment();
|
|
1748
|
-
}
|
|
1749
|
-
|
|
1750
|
-
/* Changing the service can take the chosen person out of the list. Leaving a
|
|
1751
|
-
selection that is no longer on screen is how a save ends up naming somebody
|
|
1752
|
-
the operator cannot see. */
|
|
1753
|
-
function onProviderServiceChanged() {
|
|
1754
|
-
if (!form.subjectId) return;
|
|
1755
|
-
const stillThere = providerRows.value.some(
|
|
1756
|
-
(item) => String(item._id ?? "").trim() === form.subjectId
|
|
1757
|
-
);
|
|
1758
|
-
if (stillThere) return;
|
|
1759
|
-
form.subjectId = "";
|
|
1760
|
-
form.name = "";
|
|
1761
|
-
form.subjectRole = "";
|
|
1762
|
-
residentConflict.value = null;
|
|
1763
|
-
}
|
|
1764
|
-
|
|
1765
|
-
/* ── ADD USERS IN BULK ────────────────────────────────────────────────────
|
|
1766
|
-
*
|
|
1767
|
-
* ACCESS, NOT CREDENTIALS. This grants a reader group to many people at once and
|
|
1768
|
-
* stops there - no face, no card, no PIN. Each person then adds their own
|
|
1769
|
-
* credential from their app, which attaches to the HID user this grant created
|
|
1770
|
-
* (`getProfileEnrollmentContext` reuses the existing profile identity), so
|
|
1771
|
-
* nobody has to be enrolled twice.
|
|
1772
|
-
*
|
|
1773
|
-
* It writes through `PUT /sites/:siteId/permissions`, the one call that writes
|
|
1774
|
-
* group membership, and the device users are created by the reconcile that call
|
|
1775
|
-
* triggers: `provisionPermissionUser` writes `{ name, registration }` and
|
|
1776
|
-
* nothing else. That is exactly "user record and group, credentials empty".
|
|
1777
|
-
*
|
|
1778
|
-
* THE TRAP, and the reason the saved set is fetched first: that PUT REPLACES
|
|
1779
|
-
* this reader's whole assignment set. Sending only the ticked contractors would
|
|
1780
|
-
* revoke every resident and every member on the door. The payload is always
|
|
1781
|
-
* built from the reader's existing assignments PLUS the selection.
|
|
1782
|
-
*/
|
|
1783
|
-
const bulkDialog = ref(false);
|
|
1784
|
-
const bulkLoading = ref(false);
|
|
1785
|
-
const bulkSaving = ref(false);
|
|
1786
|
-
const bulkSearch = ref("");
|
|
1787
|
-
/** The reader's CURRENT assignments, across every category. The merge base. */
|
|
1788
|
-
const bulkSaved = ref<HidAssignmentState>(new Map());
|
|
1789
|
-
/** Who is being added now. Already-granted people are never in here. */
|
|
1790
|
-
const bulkSelected = ref<Set<string>>(new Set());
|
|
1791
|
-
|
|
1792
|
-
/* NAME THE DOOR. A bulk grant is reader-scoped and the reader was chosen on the
|
|
1793
|
-
screen behind two dialogs, so the confirmation says which one rather than
|
|
1794
|
-
leaving the operator to remember. */
|
|
1795
|
-
const bulkReaderLabel = computed(() => {
|
|
1796
|
-
const reader = selectedReader.value;
|
|
1797
|
-
const name = String(reader?.name ?? "").trim() || "this HID reader";
|
|
1798
|
-
const location = String(reader?.location ?? "").trim();
|
|
1799
|
-
return location ? `${name} · ${location}` : name;
|
|
1800
|
-
});
|
|
1801
|
-
|
|
1802
|
-
/** The same people the picker offers, narrowed by this dialog's own search. */
|
|
1803
|
-
const bulkRows = computed(() => {
|
|
1804
|
-
const rows = filterProviderMembers(
|
|
1805
|
-
providerRows.value as Array<Record<string, any>>,
|
|
1806
|
-
{ search: bulkSearch.value }
|
|
1807
|
-
);
|
|
1808
|
-
return rows.map((row) => {
|
|
1809
|
-
const candidate = providerCandidateOf(row);
|
|
1810
|
-
const id = candidate.subjectId;
|
|
1811
|
-
return {
|
|
1812
|
-
subjectId: id,
|
|
1813
|
-
name: candidate.name,
|
|
1814
|
-
company: String(row.company ?? "").trim(),
|
|
1815
|
-
service: String(row.typeLabel ?? row.type ?? "").trim(),
|
|
1816
|
-
// Already on the door. Ticked and locked rather than hidden: hiding them
|
|
1817
|
-
// would make "select all" look like it had missed people, and unticking
|
|
1818
|
-
// one here would revoke access this dialog is not for revoking.
|
|
1819
|
-
granted: bulkSaved.value.has(id),
|
|
1820
|
-
};
|
|
1821
|
-
});
|
|
1822
|
-
});
|
|
1823
|
-
|
|
1824
|
-
const bulkSelectableRows = computed(() =>
|
|
1825
|
-
bulkRows.value.filter((row) => !row.granted)
|
|
1826
|
-
);
|
|
1827
|
-
const bulkSelectedCount = computed(() => bulkSelected.value.size);
|
|
1828
|
-
const bulkAllSelected = computed(
|
|
1829
|
-
() =>
|
|
1830
|
-
bulkSelectableRows.value.length > 0 &&
|
|
1831
|
-
bulkSelectableRows.value.every((row) => bulkSelected.value.has(row.subjectId))
|
|
1832
|
-
);
|
|
1833
|
-
const bulkGrantedCount = computed(
|
|
1834
|
-
() => bulkRows.value.filter((row) => row.granted).length
|
|
1835
|
-
);
|
|
1836
|
-
|
|
1837
|
-
const bulkEmptyText = computed(() => {
|
|
1838
|
-
if (bulkLoading.value) return "Loading...";
|
|
1839
|
-
if (bulkSearch.value.trim()) return "No one matches this search.";
|
|
1840
|
-
if (providerServiceFilter.value) return "No one in this service can be enrolled yet.";
|
|
1841
|
-
return "No service provider people can be enrolled at this site yet.";
|
|
1842
|
-
});
|
|
1843
|
-
|
|
1844
|
-
function isBulkSelected(subjectId: string) {
|
|
1845
|
-
return bulkSelected.value.has(subjectId);
|
|
1846
|
-
}
|
|
1847
|
-
|
|
1848
|
-
function toggleBulkRow(subjectId: string, selected: boolean) {
|
|
1849
|
-
const next = new Set(bulkSelected.value);
|
|
1850
|
-
if (selected) next.add(subjectId);
|
|
1851
|
-
else next.delete(subjectId);
|
|
1852
|
-
bulkSelected.value = next;
|
|
1853
|
-
}
|
|
1854
|
-
|
|
1855
|
-
/* Select-all acts on the ROWS IN FRONT OF THE OPERATOR - what the service filter
|
|
1856
|
-
and the search have left - never on the whole estate. A tick box cannot
|
|
1857
|
-
quietly grant a door to people who are not on screen. */
|
|
1858
|
-
function toggleBulkAll(selected: boolean) {
|
|
1859
|
-
const next = new Set(bulkSelected.value);
|
|
1860
|
-
for (const row of bulkSelectableRows.value) {
|
|
1861
|
-
if (selected) next.add(row.subjectId);
|
|
1862
|
-
else next.delete(row.subjectId);
|
|
1863
|
-
}
|
|
1864
|
-
bulkSelected.value = next;
|
|
1865
|
-
}
|
|
1866
|
-
|
|
1867
|
-
/**
|
|
1868
|
-
* Open the bulk dialog, after reading what the reader already holds.
|
|
1869
|
-
*
|
|
1870
|
-
* The saved set is fetched EVERY time rather than cached: it is the merge base
|
|
1871
|
-
* for a write that replaces the reader's assignments, and a stale one would
|
|
1872
|
-
* revoke whoever was granted in between.
|
|
1873
|
-
*/
|
|
1874
|
-
async function openBulkAdd() {
|
|
1875
|
-
const readerId =
|
|
1876
|
-
selectedReaderId.value || form.reader || readers.value[0]?._id;
|
|
1877
|
-
if (!readerId) {
|
|
1878
|
-
showToast("Select a HID reader before adding people in bulk.", "error");
|
|
1879
|
-
return;
|
|
1880
|
-
}
|
|
1881
|
-
/* The permissions endpoints REQUIRE an org - `schemaHidPermissionScopeQuery`
|
|
1882
|
-
has it as `required()`, unlike `createIdentity`, which falls back to the
|
|
1883
|
-
site's own. Both pages that mount this component pass one, so this is a
|
|
1884
|
-
guard against a 400 that would read as a server fault. */
|
|
1885
|
-
if (!props.org) {
|
|
1886
|
-
showToast(
|
|
1887
|
-
"This screen cannot grant access in bulk without an organization.",
|
|
1888
|
-
"error"
|
|
1889
|
-
);
|
|
1890
|
-
return;
|
|
1891
|
-
}
|
|
1892
|
-
bulkSearch.value = "";
|
|
1893
|
-
bulkSelected.value = new Set();
|
|
1894
|
-
bulkSaved.value = new Map();
|
|
1895
|
-
bulkDialog.value = true;
|
|
1896
|
-
bulkLoading.value = true;
|
|
1897
|
-
try {
|
|
1898
|
-
if (!providersLoaded.value) await loadProviderCandidates();
|
|
1899
|
-
const response = await getSitePermissions(props.site, props.org, readerId);
|
|
1900
|
-
bulkSaved.value = seedAssignments(response?.data?.assignments ?? []);
|
|
1901
|
-
} catch (error: unknown) {
|
|
1902
|
-
// Closed rather than left open on an empty merge base: saving from here
|
|
1903
|
-
// would send only the ticked people and revoke everybody else.
|
|
1904
|
-
bulkDialog.value = false;
|
|
1905
|
-
showToast(
|
|
1906
|
-
apiReason(error) || "Unable to read this reader's current access.",
|
|
1907
|
-
"error"
|
|
1908
|
-
);
|
|
1909
|
-
} finally {
|
|
1910
|
-
bulkLoading.value = false;
|
|
1911
|
-
}
|
|
1912
|
-
}
|
|
1913
|
-
|
|
1914
|
-
/** Grant the ticked people, keeping everyone the reader already admits. */
|
|
1915
|
-
async function saveBulkAdd() {
|
|
1916
|
-
if (bulkSaving.value || !bulkSelected.value.size) return;
|
|
1917
|
-
const readerId =
|
|
1918
|
-
selectedReaderId.value || form.reader || readers.value[0]?._id;
|
|
1919
|
-
if (!readerId) {
|
|
1920
|
-
showToast("Select a HID reader before adding people in bulk.", "error");
|
|
1921
|
-
return;
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
|
-
bulkSaving.value = true;
|
|
1925
|
-
try {
|
|
1926
|
-
// Seeded from what the server holds, so every category the reader already
|
|
1927
|
-
// admits survives this write.
|
|
1928
|
-
let draft: HidAssignmentState = new Map(bulkSaved.value);
|
|
1929
|
-
for (const subjectId of bulkSelected.value) {
|
|
1930
|
-
draft = setAssignment(
|
|
1931
|
-
draft,
|
|
1932
|
-
bulkSaved.value,
|
|
1933
|
-
subjectId,
|
|
1934
|
-
// A provider employee's grant is stored under the STAFF category: core's
|
|
1935
|
-
// `permissionSubjectOfIdentity` reads the `member` link and cannot see
|
|
1936
|
-
// this dropdown, and that category's subject list already includes these
|
|
1937
|
-
// people. See `permissionCategoryOf`.
|
|
1938
|
-
permissionCategoryOf("service_provider_staff") as THidPermissionCategory,
|
|
1939
|
-
true
|
|
1940
|
-
);
|
|
1941
|
-
}
|
|
1942
|
-
const added = bulkSelected.value.size;
|
|
1943
|
-
await updateSitePermissions(
|
|
1944
|
-
props.site,
|
|
1945
|
-
props.org,
|
|
1946
|
-
readerId,
|
|
1947
|
-
toAssignmentPayload(draft)
|
|
1948
|
-
);
|
|
1949
|
-
// Both dialogs close and the list is re-read: the reconcile has just created
|
|
1950
|
-
// a HID user for each person, so the roster behind this dialog is stale.
|
|
1951
|
-
bulkDialog.value = false;
|
|
1952
|
-
formDialog.value = false;
|
|
1953
|
-
await loadUsers();
|
|
1954
|
-
showToast(
|
|
1955
|
-
`${added} ${added === 1 ? "person" : "people"} can now use this reader. `
|
|
1956
|
-
+ "They add their own face, QR or PIN from their app.",
|
|
1957
|
-
"success"
|
|
1958
|
-
);
|
|
1959
|
-
} catch (error: unknown) {
|
|
1960
|
-
showToast(
|
|
1961
|
-
apiReason(error) || "Unable to give these people access to this reader.",
|
|
1962
|
-
"error"
|
|
1963
|
-
);
|
|
1964
|
-
} finally {
|
|
1965
|
-
bulkSaving.value = false;
|
|
1966
|
-
}
|
|
1967
|
-
}
|
|
1968
|
-
|
|
1969
1366
|
/**
|
|
1970
1367
|
* Switching type clears the chosen subject, and nothing else.
|
|
1971
1368
|
*
|
|
@@ -1975,20 +1372,15 @@ async function saveBulkAdd() {
|
|
|
1975
1372
|
* choice, so they clear with it.
|
|
1976
1373
|
*/
|
|
1977
1374
|
async function onSubjectCategoryChanged() {
|
|
1978
|
-
REMEMBERED_SUBJECT_CATEGORY.value = form.subjectCategory
|
|
1375
|
+
REMEMBERED_SUBJECT_CATEGORY.value = form.subjectCategory;
|
|
1979
1376
|
form.subjectId = "";
|
|
1980
1377
|
form.subjectRole = "";
|
|
1981
1378
|
form.name = "";
|
|
1982
1379
|
residentConflict.value = null;
|
|
1983
1380
|
|
|
1984
|
-
|
|
1985
|
-
// three options costs one request each rather than one per flip.
|
|
1986
|
-
if (isMemberSubject.value && !membersLoaded.value) {
|
|
1381
|
+
if (!isResidentSubject.value && !membersLoaded.value) {
|
|
1987
1382
|
await loadMemberCandidates();
|
|
1988
1383
|
}
|
|
1989
|
-
if (isProviderSubject.value && !providersLoaded.value) {
|
|
1990
|
-
await loadProviderCandidates();
|
|
1991
|
-
}
|
|
1992
1384
|
}
|
|
1993
1385
|
|
|
1994
1386
|
/* The staff equivalent of `onResidentChanged`. */
|
|
@@ -2547,9 +1939,7 @@ async function openEnroll() {
|
|
|
2547
1939
|
// the request must not delay it, and the picker carries its own loading
|
|
2548
1940
|
// placeholder. Fresh each opening, because staff change between them.
|
|
2549
1941
|
resetMemberCandidates();
|
|
2550
|
-
|
|
2551
|
-
if (isMemberSubject.value) await loadMemberCandidates();
|
|
2552
|
-
if (isProviderSubject.value) await loadProviderCandidates();
|
|
1942
|
+
if (!isResidentSubject.value) await loadMemberCandidates();
|
|
2553
1943
|
}
|
|
2554
1944
|
|
|
2555
1945
|
async function openEdit(user: HidUser) {
|
|
@@ -2571,21 +1961,14 @@ async function openEdit(user: HidUser) {
|
|
|
2571
1961
|
removePinRequested.value = false;
|
|
2572
1962
|
form.photoPreview = "";
|
|
2573
1963
|
facialEnrolled.value = hasFacialEnrollment(user);
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
* stale, so neither is written out any more.
|
|
2583
|
-
*/
|
|
2584
|
-
const editedSubject = subjectOfIdentity(user as Record<string, unknown>);
|
|
2585
|
-
form.subjectCategory = enrolmentSubjectOfCategory(
|
|
2586
|
-
editedSubject?.category
|
|
2587
|
-
) as TEnrolmentSubjectValue;
|
|
2588
|
-
form.subjectId = editedSubject?.subjectId ?? "";
|
|
1964
|
+
form.subjectCategory = user.person
|
|
1965
|
+
? "resident"
|
|
1966
|
+
: user.serviceProvider
|
|
1967
|
+
? "service_provider"
|
|
1968
|
+
: "property_management";
|
|
1969
|
+
form.subjectId = String(
|
|
1970
|
+
user.person || user.member || user.serviceProvider || ""
|
|
1971
|
+
);
|
|
2589
1972
|
// The resident this user already holds is not a conflict with itself. A real
|
|
2590
1973
|
// one only appears if the operator reassigns the row to somebody else.
|
|
2591
1974
|
residentConflict.value = null;
|
|
@@ -2698,8 +2081,6 @@ async function saveUser() {
|
|
|
2698
2081
|
: !selectedUser.value && !form.subjectId
|
|
2699
2082
|
? isResidentSubject.value
|
|
2700
2083
|
? "a resident"
|
|
2701
|
-
: isProviderSubject.value
|
|
2702
|
-
? "a person"
|
|
2703
2084
|
: "a member"
|
|
2704
2085
|
: !form.name
|
|
2705
2086
|
? "a name"
|
|
@@ -2758,7 +2139,27 @@ async function saveUser() {
|
|
|
2758
2139
|
// an empty string as a link being removed and then refuses the write for
|
|
2759
2140
|
// having no subject at all. Omitting the keys is what makes it keep the
|
|
2760
2141
|
// subject the record already has.
|
|
2761
|
-
const subjectLink =
|
|
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
|
+
: {};
|
|
2762
2163
|
|
|
2763
2164
|
const payload = {
|
|
2764
2165
|
hidUserId: String(hidUser.id),
|
|
@@ -3882,47 +3283,6 @@ watch(selectedReaderId, (value) => {
|
|
|
3882
3283
|
color: var(--err);
|
|
3883
3284
|
}
|
|
3884
3285
|
|
|
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
3286
|
.photo-wrap {
|
|
3927
3287
|
display: grid;
|
|
3928
3288
|
place-items: center;
|