@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
|
@@ -68,9 +68,9 @@
|
|
|
68
68
|
<!-- Visitor / Resident Credentials -->
|
|
69
69
|
<template v-if="details.userCred">
|
|
70
70
|
<v-divider />
|
|
71
|
-
|
|
71
|
+
<div class="card-details__section">
|
|
72
72
|
{{ details.userType ?? "Visitor Information" }}
|
|
73
|
-
</div>
|
|
73
|
+
</div>
|
|
74
74
|
|
|
75
75
|
<div v-if="details.userCred.email">
|
|
76
76
|
<div class="card-details__label">Email</div>
|
|
@@ -50,28 +50,18 @@
|
|
|
50
50
|
|
|
51
51
|
<!-- Person Selector -->
|
|
52
52
|
<InputLabel title="Person" required class="mb-1" />
|
|
53
|
-
|
|
54
|
-
An autocomplete rather than a select: a unit's Management and provider
|
|
55
|
-
lists are short, but a site's can run to dozens, and scrolling for a
|
|
56
|
-
name is slow. `custom-filter` widens the match beyond the title,
|
|
57
|
-
because the thing an operator remembers is often the email or the role
|
|
58
|
-
- and a member's name IS frequently their email address.
|
|
59
|
-
-->
|
|
60
|
-
<v-autocomplete
|
|
53
|
+
<v-select
|
|
61
54
|
v-model="assignPerson"
|
|
62
|
-
v-model:search="personSearch"
|
|
63
55
|
:items="assignPeopleItems"
|
|
64
|
-
:custom-filter="filterPerson"
|
|
65
56
|
density="compact"
|
|
66
57
|
item-title="name"
|
|
67
58
|
item-value="assigneeId"
|
|
68
59
|
hide-details="auto"
|
|
69
|
-
placeholder="
|
|
60
|
+
placeholder="Select person..."
|
|
70
61
|
persistent-placeholder
|
|
71
62
|
:loading="peopleLoading"
|
|
72
63
|
:disabled="peopleLoading"
|
|
73
64
|
return-object
|
|
74
|
-
:hide-no-data="false"
|
|
75
65
|
:no-data-text="assignNoDataText"
|
|
76
66
|
>
|
|
77
67
|
<!--
|
|
@@ -108,7 +98,7 @@
|
|
|
108
98
|
</div>
|
|
109
99
|
</div>
|
|
110
100
|
</template>
|
|
111
|
-
</v-
|
|
101
|
+
</v-select>
|
|
112
102
|
|
|
113
103
|
<!--
|
|
114
104
|
A card is assigned by writing the person's USER id onto it, so a row
|
|
@@ -465,20 +455,6 @@ const { getEncryptedAcmUrl } = useSiteEntryPassSettings();
|
|
|
465
455
|
const { getAll: getMembers } = useMember();
|
|
466
456
|
const { getSiteMembers } = useServiceProvider();
|
|
467
457
|
|
|
468
|
-
/**
|
|
469
|
-
* The member type of whichever app this layer is running in
|
|
470
|
-
* ("property_management_agency", "security_agency", ...), which `/api/members`
|
|
471
|
-
* requires and rejects empty.
|
|
472
|
-
*
|
|
473
|
-
* Read HERE, during setup, not inside the loader that uses it:
|
|
474
|
-
* `useRuntimeConfig` needs the Nuxt instance, and that is only guaranteed
|
|
475
|
-
* synchronously in setup. Called from an async function resumed after an
|
|
476
|
-
* await — which is exactly what the loader is — it throws "nuxt instance
|
|
477
|
-
* unavailable", the loader's catch turns that into an empty list, and the
|
|
478
|
-
* Management group silently shows nothing with no request ever sent.
|
|
479
|
-
*/
|
|
480
|
-
const APP = String(useRuntimeConfig().public.APP ?? "");
|
|
481
|
-
|
|
482
458
|
const historyDialog = ref(false);
|
|
483
459
|
const showAssignForm = ref(false);
|
|
484
460
|
const assignPerson = ref<Record<string, any> | null>(null);
|
|
@@ -495,22 +471,10 @@ const assignPerson = ref<Record<string, any> | null>(null);
|
|
|
495
471
|
*/
|
|
496
472
|
type AssignCategory = "residents" | "management" | "providers";
|
|
497
473
|
|
|
498
|
-
const assignCategories: {
|
|
499
|
-
value:
|
|
500
|
-
label:
|
|
501
|
-
|
|
502
|
-
* What the server stores as the card's `holderType` (core
|
|
503
|
-
* `EAccessCardHolderTypes`). Sent with the assignment, because `assignees`
|
|
504
|
-
* alone is ambiguous: it is a `users` id for a resident and a membership id
|
|
505
|
-
* for a provider, and only the group says which to resolve against. These
|
|
506
|
-
* strings are a server contract - the labels above are free to change, these
|
|
507
|
-
* are not.
|
|
508
|
-
*/
|
|
509
|
-
holderType: "Visitor/Resident" | "Management" | "Service Provider";
|
|
510
|
-
}[] = [
|
|
511
|
-
{ value: "residents", label: "Resident / Tenant", holderType: "Visitor/Resident" },
|
|
512
|
-
{ value: "management", label: "Management", holderType: "Management" },
|
|
513
|
-
{ value: "providers", label: "Service Provider", holderType: "Service Provider" },
|
|
474
|
+
const assignCategories: { value: AssignCategory; label: string }[] = [
|
|
475
|
+
{ value: "residents", label: "Resident / Tenant" },
|
|
476
|
+
{ value: "management", label: "Management" },
|
|
477
|
+
{ value: "providers", label: "Service Provider" },
|
|
514
478
|
];
|
|
515
479
|
|
|
516
480
|
const assignCategory = ref<AssignCategory>("residents");
|
|
@@ -536,30 +500,6 @@ type AssignRow = {
|
|
|
536
500
|
};
|
|
537
501
|
|
|
538
502
|
const assignPeopleItems = ref<AssignRow[]>([]);
|
|
539
|
-
|
|
540
|
-
/** What is typed in the person picker. Cleared whenever the group changes. */
|
|
541
|
-
const personSearch = ref("");
|
|
542
|
-
|
|
543
|
-
/**
|
|
544
|
-
* Match a person on everything the row shows, not just the name.
|
|
545
|
-
*
|
|
546
|
-
* Vuetify filters on the title alone by default, which is too narrow here: a
|
|
547
|
-
* Management member's name is often their email address, and two provider
|
|
548
|
-
* people from one company are told apart only by their role. Both of those sit
|
|
549
|
-
* in the second line, so a name-only match would hide the row an operator is
|
|
550
|
-
* actually searching for.
|
|
551
|
-
*
|
|
552
|
-
* The first argument is the title Vuetify matched against; it is ignored in
|
|
553
|
-
* favour of the row itself, which carries all three fields.
|
|
554
|
-
*/
|
|
555
|
-
function filterPerson(_title: string, query: string, item: any): boolean {
|
|
556
|
-
const q = String(query ?? "").trim().toLowerCase();
|
|
557
|
-
if (!q) return true;
|
|
558
|
-
const row = item?.raw ?? {};
|
|
559
|
-
return [row.name, row.subtitle, row.meta].some((field) =>
|
|
560
|
-
String(field ?? "").toLowerCase().includes(q),
|
|
561
|
-
);
|
|
562
|
-
}
|
|
563
503
|
const peopleLoading = ref(false);
|
|
564
504
|
const assignLoading = ref(false);
|
|
565
505
|
const assignError = ref("");
|
|
@@ -657,7 +597,7 @@ async function loadManagementRows(): Promise<AssignRow[]> {
|
|
|
657
597
|
org: props.orgId,
|
|
658
598
|
siteId: props.siteId,
|
|
659
599
|
unit: props.unit._id,
|
|
660
|
-
type: APP,
|
|
600
|
+
type: String(useRuntimeConfig().public.APP ?? ""),
|
|
661
601
|
status: "active",
|
|
662
602
|
page: 1,
|
|
663
603
|
limit: 50,
|
|
@@ -738,19 +678,9 @@ async function loadAssignPeople() {
|
|
|
738
678
|
: await loadResidentRows();
|
|
739
679
|
if (seq !== assignLoadSeq) return;
|
|
740
680
|
assignPeopleItems.value = rows;
|
|
741
|
-
} catch
|
|
681
|
+
} catch {
|
|
742
682
|
if (seq !== assignLoadSeq) return;
|
|
743
683
|
assignPeopleItems.value = [];
|
|
744
|
-
/*
|
|
745
|
-
* Say that the list failed rather than showing an empty one.
|
|
746
|
-
*
|
|
747
|
-
* This swallowed everything, so a group whose request 400'd, or never got
|
|
748
|
-
* sent at all, looked exactly like a group with nobody in it - there was
|
|
749
|
-
* nothing on screen to tell the two apart, and no way to tell from the UI
|
|
750
|
-
* that anything had gone wrong.
|
|
751
|
-
*/
|
|
752
|
-
assignError.value =
|
|
753
|
-
e?.data?.message ?? e?.message ?? "Could not load the list. Please try again.";
|
|
754
684
|
} finally {
|
|
755
685
|
if (seq === assignLoadSeq) peopleLoading.value = false;
|
|
756
686
|
}
|
|
@@ -758,9 +688,6 @@ async function loadAssignPeople() {
|
|
|
758
688
|
|
|
759
689
|
function onAssignCategoryChange() {
|
|
760
690
|
assignPerson.value = null;
|
|
761
|
-
// A term typed for the previous group means nothing in the next one, and
|
|
762
|
-
// leaving it would show an empty list over a list that has people in it.
|
|
763
|
-
personSearch.value = "";
|
|
764
691
|
assignError.value = "";
|
|
765
692
|
loadAssignPeople();
|
|
766
693
|
}
|
|
@@ -768,7 +695,6 @@ function onAssignCategoryChange() {
|
|
|
768
695
|
async function openAssignForm() {
|
|
769
696
|
showAssignForm.value = true;
|
|
770
697
|
assignPerson.value = null;
|
|
771
|
-
personSearch.value = "";
|
|
772
698
|
assignCategory.value = "residents";
|
|
773
699
|
assignError.value = "";
|
|
774
700
|
assignPeopleItems.value = [];
|
|
@@ -788,7 +714,6 @@ async function openAssignForm() {
|
|
|
788
714
|
function cancelAssign() {
|
|
789
715
|
showAssignForm.value = false;
|
|
790
716
|
assignPerson.value = null;
|
|
791
|
-
personSearch.value = "";
|
|
792
717
|
assignCategory.value = "residents";
|
|
793
718
|
assignPeopleItems.value = [];
|
|
794
719
|
assignError.value = "";
|
|
@@ -819,8 +744,6 @@ async function confirmAssign() {
|
|
|
819
744
|
type: selectedCardType.value,
|
|
820
745
|
acm_url: encryptedAcmUrl.value,
|
|
821
746
|
id: props.selectedCardInUnit?._id,
|
|
822
|
-
holderType: assignCategories.find((c) => c.value === assignCategory.value)
|
|
823
|
-
?.holderType,
|
|
824
747
|
});
|
|
825
748
|
emit("assign-to-person", {
|
|
826
749
|
card: props.selectedCardInUnit,
|
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
<template v-else>
|
|
27
27
|
<div class="hid-access-permissions__intro">
|
|
28
28
|
<p class="text-body-2 text-medium-emphasis mb-0">
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
Residents enrolled on this reader. Enrolling already grants access — switch
|
|
30
|
+
somebody off to close the door to them without removing their face, card
|
|
31
|
+
or PIN.
|
|
32
32
|
</p>
|
|
33
33
|
</div>
|
|
34
34
|
|
|
@@ -45,36 +45,12 @@
|
|
|
45
45
|
@refresh="loadEnrolled"
|
|
46
46
|
@update:page="goToPage"
|
|
47
47
|
>
|
|
48
|
-
<!-- WHICH OF THE READER'S PEOPLE TO SHOW.
|
|
49
|
-
The reader holds residents, members and contractors; each tab is
|
|
50
|
-
one `type`, filtered and paged by the server. The strip is
|
|
51
|
-
`TableMain`'s own slot and the shared `.app-tab` markup, the same
|
|
52
|
-
as every other tabbed table here - not a `v-tabs`, whose selected
|
|
53
|
-
state is drawn from Vuetify's `on-surface` rather than the
|
|
54
|
-
design's accent underline.
|
|
55
|
-
The count beside each label is the whole reader's, from the
|
|
56
|
-
draft, so it follows an unsaved tick. -->
|
|
57
|
-
<template #tabs>
|
|
58
|
-
<button
|
|
59
|
-
v-for="tab in TABS"
|
|
60
|
-
:key="tab.value"
|
|
61
|
-
type="button"
|
|
62
|
-
class="app-tab"
|
|
63
|
-
:class="{ 'app-tab--active': activeTab === tab.value }"
|
|
64
|
-
:aria-pressed="activeTab === tab.value"
|
|
65
|
-
@click="selectTab(tab.value)"
|
|
66
|
-
>
|
|
67
|
-
{{ tab.label }}
|
|
68
|
-
<span class="hid-access-permissions__tab-count">{{ tabCount(tab) }}</span>
|
|
69
|
-
</button>
|
|
70
|
-
</template>
|
|
71
|
-
|
|
72
48
|
<template #extension>
|
|
73
49
|
<div class="app-filter-row">
|
|
74
50
|
<AppField
|
|
75
51
|
v-model="search"
|
|
76
52
|
search
|
|
77
|
-
|
|
53
|
+
placeholder="Search name, unit or registration"
|
|
78
54
|
@keyup.enter="searchFromFirstPage"
|
|
79
55
|
/>
|
|
80
56
|
</div>
|
|
@@ -145,7 +121,6 @@ import {
|
|
|
145
121
|
readCandidatePage,
|
|
146
122
|
seedAssignments,
|
|
147
123
|
setAssignment,
|
|
148
|
-
subjectOfIdentity,
|
|
149
124
|
toAssignmentPayload,
|
|
150
125
|
type HidAssignmentState,
|
|
151
126
|
} from "../utils/hid-permission-assignments";
|
|
@@ -181,52 +156,11 @@ const emit = defineEmits<{
|
|
|
181
156
|
|
|
182
157
|
const { getSitePermissions, getIdentities, updateSitePermissions } = useHidAmico();
|
|
183
158
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
* `type` is the identity's own, filtered by the SERVER (`listIdentities` does an
|
|
188
|
-
* exact match on it), and it is what the enrolment writes and what
|
|
189
|
-
* `permissionIdentityType` re-confirms on every reconcile. `category` is the
|
|
190
|
-
* permission category a NEW grant from this tab belongs to.
|
|
191
|
-
*
|
|
192
|
-
* The two are a pair rather than one derived from the other, because the tab is
|
|
193
|
-
* the only thing on this screen that knows which of them the operator means: a
|
|
194
|
-
* contractor and a property-management member are both a `member` link, and
|
|
195
|
-
* guessing between them from the record is exactly what went wrong before.
|
|
196
|
-
*/
|
|
197
|
-
const TABS = [
|
|
198
|
-
{ value: "resident", label: "Resident", noun: "Resident", type: "resident", category: "resident" },
|
|
199
|
-
{ value: "member", label: "Member", noun: "Member", type: "staff", category: "property_management" },
|
|
200
|
-
{
|
|
201
|
-
value: "provider",
|
|
202
|
-
label: "Service provider",
|
|
203
|
-
noun: "Person",
|
|
204
|
-
type: "contractor",
|
|
205
|
-
category: "service_provider_member",
|
|
206
|
-
},
|
|
207
|
-
] as const satisfies ReadonlyArray<{
|
|
208
|
-
value: string;
|
|
209
|
-
label: string;
|
|
210
|
-
noun: string;
|
|
211
|
-
type: string;
|
|
212
|
-
category: THidPermissionCategory;
|
|
213
|
-
}>;
|
|
214
|
-
|
|
215
|
-
const activeTab = ref<(typeof TABS)[number]["value"]>("resident");
|
|
216
|
-
const currentTab = computed(
|
|
217
|
-
() => TABS.find((tab) => tab.value === activeTab.value) ?? TABS[0],
|
|
218
|
-
);
|
|
219
|
-
|
|
220
|
-
/** Granted counts per category, for the tab strip. One call, all three. */
|
|
221
|
-
const counts = ref<Record<string, number>>({});
|
|
222
|
-
const tabCount = (tab: (typeof TABS)[number]) => counts.value[tab.category] ?? 0;
|
|
223
|
-
|
|
224
|
-
/* Name over unit in one cell rather than a "Unit" column — see `subtitleOf`.
|
|
225
|
-
The first column follows the tab; "Door access" is the same action for all. */
|
|
226
|
-
const headers = computed(() => [
|
|
227
|
-
{ title: currentTab.value.noun, value: "name", sortable: false },
|
|
159
|
+
/* Name over unit in one cell rather than a "Unit" column — see `subtitleOf`. */
|
|
160
|
+
const headers = [
|
|
161
|
+
{ title: "Resident", value: "name", sortable: false },
|
|
228
162
|
{ title: "Door access", value: "selected", sortable: false, width: 190 },
|
|
229
|
-
]
|
|
163
|
+
];
|
|
230
164
|
|
|
231
165
|
const search = ref("");
|
|
232
166
|
const page = ref(1);
|
|
@@ -253,13 +187,6 @@ const readerLabel = computed(() => {
|
|
|
253
187
|
return readerLocation.value ? `${name} · ${readerLocation.value}` : name;
|
|
254
188
|
});
|
|
255
189
|
|
|
256
|
-
/* "unit" only means something for a resident. */
|
|
257
|
-
const searchPlaceholder = computed(() =>
|
|
258
|
-
activeTab.value === "resident"
|
|
259
|
-
? "Search name, unit or registration"
|
|
260
|
-
: "Search name or registration",
|
|
261
|
-
);
|
|
262
|
-
|
|
263
190
|
const assignedTotal = computed(() => draft.value.size);
|
|
264
191
|
const dirty = computed(() => hasChanges(savedState.value, draft.value));
|
|
265
192
|
/* The draft is the truth once the dialog is open, so `selected` is recomputed
|
|
@@ -272,7 +199,6 @@ watch(
|
|
|
272
199
|
if (!isOpen || !readerId.value) return;
|
|
273
200
|
search.value = "";
|
|
274
201
|
page.value = 1;
|
|
275
|
-
activeTab.value = "resident";
|
|
276
202
|
void loadAll();
|
|
277
203
|
},
|
|
278
204
|
{ immediate: true },
|
|
@@ -295,13 +221,6 @@ async function loadAssignments() {
|
|
|
295
221
|
const assignments = response?.data?.assignments ?? [];
|
|
296
222
|
savedState.value = seedAssignments(assignments);
|
|
297
223
|
draft.value = seedAssignments(assignments);
|
|
298
|
-
/*
|
|
299
|
-
* Counted from the assignments themselves rather than the server's `counts`
|
|
300
|
-
* block, so the strip follows the DRAFT and a tick updates the number it
|
|
301
|
-
* sits beside. `countByCategory` is the same helper the candidate screens
|
|
302
|
-
* use.
|
|
303
|
-
*/
|
|
304
|
-
counts.value = countByCategory(draft.value);
|
|
305
224
|
} catch (error) {
|
|
306
225
|
showToast(getErrorMessage(error, "Unable to load this reader's permissions."), "error");
|
|
307
226
|
}
|
|
@@ -311,6 +230,27 @@ function toRecord(value: unknown): Record<string, unknown> {
|
|
|
311
230
|
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
|
312
231
|
}
|
|
313
232
|
|
|
233
|
+
/**
|
|
234
|
+
* The subject an identity names, in the order `createIdentity` validates them.
|
|
235
|
+
*
|
|
236
|
+
* A visitor returns null and is left out of the list: visitor access comes from
|
|
237
|
+
* `user_access_rules`, not from a group, so there is nothing here to switch
|
|
238
|
+
* off. An identity linked to nobody — a device user the reader holds that was
|
|
239
|
+
* never tied to a person — is dropped for the same reason.
|
|
240
|
+
*/
|
|
241
|
+
function subjectOf(identity: Record<string, unknown>) {
|
|
242
|
+
if (identity.person) {
|
|
243
|
+
return { subjectId: String(identity.person), category: "resident" as const };
|
|
244
|
+
}
|
|
245
|
+
if (identity.member) {
|
|
246
|
+
return { subjectId: String(identity.member), category: "property_management" as const };
|
|
247
|
+
}
|
|
248
|
+
if (identity.serviceProvider) {
|
|
249
|
+
return { subjectId: String(identity.serviceProvider), category: "service_provider" as const };
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
314
254
|
/**
|
|
315
255
|
* Why a row cannot be switched, in words the reader of the screen can act on.
|
|
316
256
|
*
|
|
@@ -354,10 +294,10 @@ async function loadEnrolled() {
|
|
|
354
294
|
page: page.value,
|
|
355
295
|
limit: 10,
|
|
356
296
|
search: search.value.trim(),
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
type:
|
|
297
|
+
// Residents only, and filtered by the SERVER rather than here. The pager's
|
|
298
|
+
// count comes back from the same query, so narrowing the list on this side
|
|
299
|
+
// would print a total that never matched the rows under it.
|
|
300
|
+
type: "resident",
|
|
361
301
|
});
|
|
362
302
|
const result = readCandidatePage(response);
|
|
363
303
|
/*
|
|
@@ -369,13 +309,11 @@ async function loadEnrolled() {
|
|
|
369
309
|
*/
|
|
370
310
|
enrolled.value = result.items.map((row) => {
|
|
371
311
|
const identity = toRecord(row);
|
|
372
|
-
const subject =
|
|
312
|
+
const subject = subjectOf(identity);
|
|
373
313
|
const metadata = toRecord(identity.metadata);
|
|
374
314
|
return {
|
|
375
315
|
subjectId: subject?.subjectId ?? "",
|
|
376
|
-
|
|
377
|
-
// which is the only thing that knows what the operator means.
|
|
378
|
-
category: subject?.category ?? currentTab.value.category,
|
|
316
|
+
category: subject?.category ?? "resident",
|
|
379
317
|
// The row's own key. `subjectId` is empty for the rows below, and two
|
|
380
318
|
// empty keys would collapse into one row in the table.
|
|
381
319
|
rowKey: String(identity._id ?? identity.hidUserId ?? Math.random()),
|
|
@@ -404,24 +342,6 @@ async function loadEnrolled() {
|
|
|
404
342
|
}
|
|
405
343
|
}
|
|
406
344
|
|
|
407
|
-
/**
|
|
408
|
-
* Switching tabs refetches, because the filter is the SERVER's.
|
|
409
|
-
*
|
|
410
|
-
* The draft is untouched: it is the whole reader's, and the save sends all of it
|
|
411
|
-
* whatever tab is in front of you. Clearing it here - or building the payload
|
|
412
|
-
* from the loaded rows - is what would revoke everybody on the other two tabs.
|
|
413
|
-
*
|
|
414
|
-
* Search is cleared with the tab. It is a server-side search within one type, so
|
|
415
|
-
* carrying it across would land on an empty list and look like the tab was empty.
|
|
416
|
-
*/
|
|
417
|
-
function selectTab(value: (typeof TABS)[number]["value"]) {
|
|
418
|
-
if (activeTab.value === value) return;
|
|
419
|
-
activeTab.value = value;
|
|
420
|
-
search.value = "";
|
|
421
|
-
page.value = 1;
|
|
422
|
-
void loadEnrolled();
|
|
423
|
-
}
|
|
424
|
-
|
|
425
345
|
function goToPage(value: number) {
|
|
426
346
|
page.value = Number(value) || 1;
|
|
427
347
|
void loadEnrolled();
|
|
@@ -432,34 +352,15 @@ function searchFromFirstPage() {
|
|
|
432
352
|
void loadEnrolled();
|
|
433
353
|
}
|
|
434
354
|
|
|
435
|
-
/**
|
|
436
|
-
* Grant or revoke one person.
|
|
437
|
-
*
|
|
438
|
-
* The category comes from the row where the server gave it one, and otherwise
|
|
439
|
-
* from the TAB - never from re-reading the identity's links on this side. A
|
|
440
|
-
* contractor and a property-management member are both a `member` link, and the
|
|
441
|
-
* stale local copy of that reading is what used to file contractors as staff: the
|
|
442
|
-
* reconcile then derived `staff` from that category and rewrote the person's
|
|
443
|
-
* type, moving them into the Member tab.
|
|
444
|
-
*
|
|
445
|
-
* An ALREADY GRANTED person keeps the category their assignment holds, so merely
|
|
446
|
-
* visiting a tab and toggling somebody off and on cannot re-file them.
|
|
447
|
-
*/
|
|
448
355
|
function toggle(item: THidPermissionCandidate, selected: boolean) {
|
|
449
|
-
|
|
450
|
-
if (!subjectId) return;
|
|
451
|
-
const category = savedState.value.get(subjectId)?.category
|
|
452
|
-
?? item.category
|
|
453
|
-
?? currentTab.value.category;
|
|
356
|
+
if (!String(item.subjectId ?? "")) return;
|
|
454
357
|
draft.value = setAssignment(
|
|
455
358
|
draft.value,
|
|
456
359
|
savedState.value,
|
|
457
|
-
subjectId,
|
|
458
|
-
category,
|
|
360
|
+
String(item.subjectId ?? ""),
|
|
361
|
+
item.category,
|
|
459
362
|
selected,
|
|
460
363
|
);
|
|
461
|
-
// The strip sits beside the rows, so it follows the tick rather than the save.
|
|
462
|
-
counts.value = countByCategory(draft.value);
|
|
463
364
|
}
|
|
464
365
|
|
|
465
366
|
async function save() {
|
|
@@ -500,15 +401,6 @@ function getErrorMessage(error: unknown, fallback: string) {
|
|
|
500
401
|
</script>
|
|
501
402
|
|
|
502
403
|
<style scoped lang="scss">
|
|
503
|
-
/* The granted count beside a tab label. Quieter than the label and set in
|
|
504
|
-
tabular figures so the three tabs do not shift width as numbers change. */
|
|
505
|
-
.hid-access-permissions__tab-count {
|
|
506
|
-
margin-left: 6px;
|
|
507
|
-
color: var(--muted);
|
|
508
|
-
font-weight: 600;
|
|
509
|
-
font-variant-numeric: tabular-nums;
|
|
510
|
-
}
|
|
511
|
-
|
|
512
404
|
.hid-access-permissions {
|
|
513
405
|
&__title { padding-block: 16px; }
|
|
514
406
|
|