@7365admin1/layer-common 4.149.2 → 4.150.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 +10 -4
- package/components/EntryPassInformation.vue +13 -0
- package/components/HidAccessPermissions.vue +202 -40
- package/components/HidEnabledGate.vue +29 -10
- package/components/HidIntercomManagement.vue +26 -13
- package/components/HidProfileQrCode.vue +6 -2
- package/components/HidQrCodeConfiguration.vue +2 -2
- package/components/HidReaderForm.vue +239 -1
- package/components/HidReaderManagement.vue +26 -8
- package/components/HidReaderUserRoster.vue +3 -1
- package/components/HidServiceSettingsPanel.vue +2 -2
- package/components/HidUserEnrollment.vue +1662 -162
- package/components/OvernightParkingAvailability.vue +123 -11
- package/components/SiteSettings.vue +5 -2
- package/components/VisitorForm.vue +39 -11
- package/composables/useCleaningPermission.ts +11 -0
- package/composables/useHidAccessPermission.ts +176 -81
- package/composables/useHidAmico.ts +37 -0
- package/composables/useHidNavigation.ts +44 -23
- package/composables/useLandscapingPermission.ts +11 -0
- package/composables/useMechanicalElectricalPermission.ts +5 -0
- package/composables/usePestControlPermission.ts +11 -0
- package/composables/usePoolMaintenancePermission.ts +11 -0
- package/composables/usePropertyPermission.ts +31 -7
- package/composables/useSecurityPermission.ts +33 -13
- package/composables/useServiceProvider.ts +9 -4
- package/constants/permissions.ts +73 -21
- package/package.json +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +3 -0
- package/types/overnight-parking.d.ts +14 -0
- package/types/site.d.ts +7 -1
- package/utils/hid-enrolment-subject.ts +226 -4
- package/utils/hid-permission-assignments.ts +75 -6
- package/utils/hid-qr-validity.ts +25 -0
- package/utils/hid-reader-audiences.ts +557 -0
- package/utils/hid-registration.ts +24 -0
- package/utils/module-routes.ts +15 -2
- package/utils/nav-sections.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.150.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- b43efad: HID Access, the staging work released to main (owner decision 2026-10-28). The HID management permission group with five flags, menus and buttons following them, Use HID access in the M&E role catalogue, and the menu group, settings panel and service named HID Reader; a reader says who it is for and shows only the matching tabs and enrolling types, a person is enrolled on every reader that serves them with a result per reader and updated and deleted on each; a service provider's own people are enrolled one at a time or in bulk, with the registration number taken from the reader being enrolled on; the visitor form offers HID QR Code again, lists the enrollment readers, issues the pass for the whole visit day and says how long it lasts, with the site choosing whole day or single use in its visitor settings.
|
|
8
|
+
|
|
3
9
|
## 4.149.2
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
|
@@ -3548,11 +3554,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
|
|
|
3548
3554
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
3549
3555
|
own rule from two endpoints the console already calls, unprojected:
|
|
3550
3556
|
|
|
3551
|
-
|
|
3552
|
-
|
|
3557
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
3558
|
+
GET /api/roles/id/:role that membership's role document
|
|
3553
3559
|
|
|
3554
|
-
|
|
3555
|
-
|
|
3560
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
3561
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
3556
3562
|
|
|
3557
3563
|
`role.default` is the marker because it is the only property of a platform
|
|
3558
3564
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -206,6 +206,13 @@
|
|
|
206
206
|
</div>
|
|
207
207
|
</div>
|
|
208
208
|
</v-card>
|
|
209
|
+
<!-- How long the pass lasts, from the site's setting; the same words
|
|
210
|
+
as the resident app's invite form. -->
|
|
211
|
+
<div v-if="hidQrUsage" class="text-caption text-primary font-weight-medium mt-2">
|
|
212
|
+
{{ hidQrUsage === "singleUse"
|
|
213
|
+
? "HID: Single use, expires after the first entry"
|
|
214
|
+
: "HID: Valid all day, until 11:59 pm" }}
|
|
215
|
+
</div>
|
|
209
216
|
<v-alert
|
|
210
217
|
v-if="!hidPortalName"
|
|
211
218
|
type="warning"
|
|
@@ -341,6 +348,12 @@ const props = defineProps({
|
|
|
341
348
|
type: String,
|
|
342
349
|
default: "",
|
|
343
350
|
},
|
|
351
|
+
// The site's HID pass usage, for the hint under the HID QR Code card. Null
|
|
352
|
+
// shows no hint (not loaded, or a caller that does not pass it).
|
|
353
|
+
hidQrUsage: {
|
|
354
|
+
type: String as PropType<"wholeDay" | "singleUse" | null>,
|
|
355
|
+
default: null,
|
|
356
|
+
},
|
|
344
357
|
excludedCardIds: {
|
|
345
358
|
type: Array as PropType<string[]>,
|
|
346
359
|
default: () => [],
|
|
@@ -20,15 +20,23 @@
|
|
|
20
20
|
density="compact"
|
|
21
21
|
class="ma-4"
|
|
22
22
|
>
|
|
23
|
-
Set a location on this HID
|
|
23
|
+
Set a location on this HID Reader before managing permissions.
|
|
24
24
|
</v-alert>
|
|
25
25
|
|
|
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
|
-
somebody off to close the door to them without removing
|
|
31
|
-
or PIN.
|
|
29
|
+
Everybody enrolled on this reader, by type. Enrolling already grants
|
|
30
|
+
access — switch somebody off to close the door to them without removing
|
|
31
|
+
their face, card or PIN.
|
|
32
|
+
</p>
|
|
33
|
+
<!-- Why a tab is missing. Left unsaid, an absent tab reads as a broken
|
|
34
|
+
screen rather than as a setting on the reader. -->
|
|
35
|
+
<p
|
|
36
|
+
v-if="audienceNote"
|
|
37
|
+
class="text-body-2 text-medium-emphasis mb-0 hid-access-permissions__audience-note"
|
|
38
|
+
>
|
|
39
|
+
{{ audienceNote }}
|
|
32
40
|
</p>
|
|
33
41
|
</div>
|
|
34
42
|
|
|
@@ -45,12 +53,36 @@
|
|
|
45
53
|
@refresh="loadEnrolled"
|
|
46
54
|
@update:page="goToPage"
|
|
47
55
|
>
|
|
56
|
+
<!-- WHICH OF THE READER'S PEOPLE TO SHOW.
|
|
57
|
+
The reader holds residents, members and contractors; each tab is
|
|
58
|
+
one `type`, filtered and paged by the server. The strip is
|
|
59
|
+
`TableMain`'s own slot and the shared `.app-tab` markup, the same
|
|
60
|
+
as every other tabbed table here - not a `v-tabs`, whose selected
|
|
61
|
+
state is drawn from Vuetify's `on-surface` rather than the
|
|
62
|
+
design's accent underline.
|
|
63
|
+
The count beside each label is the whole reader's, from the
|
|
64
|
+
draft, so it follows an unsaved tick. -->
|
|
65
|
+
<template #tabs>
|
|
66
|
+
<button
|
|
67
|
+
v-for="tab in visibleTabs"
|
|
68
|
+
:key="tab.value"
|
|
69
|
+
type="button"
|
|
70
|
+
class="app-tab"
|
|
71
|
+
:class="{ 'app-tab--active': activeTab === tab.value }"
|
|
72
|
+
:aria-pressed="activeTab === tab.value"
|
|
73
|
+
@click="selectTab(tab.value)"
|
|
74
|
+
>
|
|
75
|
+
{{ tab.label }}
|
|
76
|
+
<span class="hid-access-permissions__tab-count">{{ tabCount(tab) }}</span>
|
|
77
|
+
</button>
|
|
78
|
+
</template>
|
|
79
|
+
|
|
48
80
|
<template #extension>
|
|
49
81
|
<div class="app-filter-row">
|
|
50
82
|
<AppField
|
|
51
83
|
v-model="search"
|
|
52
84
|
search
|
|
53
|
-
placeholder="
|
|
85
|
+
:placeholder="searchPlaceholder"
|
|
54
86
|
@keyup.enter="searchFromFirstPage"
|
|
55
87
|
/>
|
|
56
88
|
</div>
|
|
@@ -97,7 +129,11 @@
|
|
|
97
129
|
</span>
|
|
98
130
|
<v-spacer />
|
|
99
131
|
<AppButton variant="ghost" :disabled="saving" @click="open = false">Cancel</AppButton>
|
|
132
|
+
<!-- Reachable only from a menu item the users right draws; the same
|
|
133
|
+
right is asked again here so the dialog stays read only if it is
|
|
134
|
+
ever opened some other way. -->
|
|
100
135
|
<AppButton
|
|
136
|
+
v-if="canManageHidUsers"
|
|
101
137
|
icon="mdi-content-save-outline"
|
|
102
138
|
:loading="saving"
|
|
103
139
|
:disabled="!readerLocation || !dirty || saving"
|
|
@@ -121,9 +157,13 @@ import {
|
|
|
121
157
|
readCandidatePage,
|
|
122
158
|
seedAssignments,
|
|
123
159
|
setAssignment,
|
|
160
|
+
subjectOfIdentity,
|
|
124
161
|
toAssignmentPayload,
|
|
125
162
|
type HidAssignmentState,
|
|
126
163
|
} from "../utils/hid-permission-assignments";
|
|
164
|
+
/* Mirrors core's audience rules — core is what enforces them; this only decides
|
|
165
|
+
which tabs are worth drawing. */
|
|
166
|
+
import { visibleAudienceTabs } from "../utils/hid-reader-audiences";
|
|
127
167
|
|
|
128
168
|
/**
|
|
129
169
|
* WHO MAY PASS THIS READER — a review and revoke screen, not a picker.
|
|
@@ -149,6 +189,8 @@ const props = defineProps<{
|
|
|
149
189
|
reader: Record<string, unknown> | null;
|
|
150
190
|
}>();
|
|
151
191
|
|
|
192
|
+
const { canManageHidUsers } = useHidAccessPermission();
|
|
193
|
+
|
|
152
194
|
const emit = defineEmits<{
|
|
153
195
|
(event: "update:modelValue", value: boolean): void;
|
|
154
196
|
(event: "saved"): void;
|
|
@@ -156,11 +198,79 @@ const emit = defineEmits<{
|
|
|
156
198
|
|
|
157
199
|
const { getSitePermissions, getIdentities, updateSitePermissions } = useHidAmico();
|
|
158
200
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
201
|
+
/**
|
|
202
|
+
* WHO EACH TAB LISTS, AND WHAT A GRANT MADE FROM IT IS FILED UNDER.
|
|
203
|
+
*
|
|
204
|
+
* `type` is the identity's own, filtered by the SERVER (`listIdentities` does an
|
|
205
|
+
* exact match on it), and it is what the enrolment writes and what
|
|
206
|
+
* `permissionIdentityType` re-confirms on every reconcile. `category` is the
|
|
207
|
+
* permission category a NEW grant from this tab belongs to.
|
|
208
|
+
*
|
|
209
|
+
* The two are a pair rather than one derived from the other, because the tab is
|
|
210
|
+
* the only thing on this screen that knows which of them the operator means: a
|
|
211
|
+
* contractor and a property-management member are both a `member` link, and
|
|
212
|
+
* guessing between them from the record is exactly what went wrong before.
|
|
213
|
+
*/
|
|
214
|
+
const TABS = [
|
|
215
|
+
{ value: "resident", label: "Resident", noun: "Resident", type: "resident", category: "resident" },
|
|
216
|
+
{ value: "member", label: "Member", noun: "Member", type: "staff", category: "property_management" },
|
|
217
|
+
{
|
|
218
|
+
value: "provider",
|
|
219
|
+
label: "Service provider",
|
|
220
|
+
noun: "Person",
|
|
221
|
+
type: "contractor",
|
|
222
|
+
category: "service_provider_member",
|
|
223
|
+
},
|
|
224
|
+
] as const satisfies ReadonlyArray<{
|
|
225
|
+
value: string;
|
|
226
|
+
label: string;
|
|
227
|
+
noun: string;
|
|
228
|
+
type: string;
|
|
229
|
+
category: THidPermissionCategory;
|
|
230
|
+
}>;
|
|
231
|
+
|
|
232
|
+
const activeTab = ref<(typeof TABS)[number]["value"]>("resident");
|
|
233
|
+
|
|
234
|
+
/*
|
|
235
|
+
* ONLY THE TABS THIS READER SERVES.
|
|
236
|
+
*
|
|
237
|
+
* A reader whose audiences exclude a category cannot hold a grant in it - core
|
|
238
|
+
* refuses the save - so a tab for it would be a list the operator can look at
|
|
239
|
+
* and never act on. See `visibleAudienceTabs`, which also keeps one tab when a
|
|
240
|
+
* reader serves nobody, because a dialog with no tabs and no explanation reads
|
|
241
|
+
* as a broken screen.
|
|
242
|
+
*
|
|
243
|
+
* A reader saved before audiences existed has none, and that reads as all four -
|
|
244
|
+
* so this changes nothing for one.
|
|
245
|
+
*/
|
|
246
|
+
const audienceTabs = computed(() => visibleAudienceTabs(props.reader, TABS));
|
|
247
|
+
const visibleTabs = computed(() => audienceTabs.value.tabs);
|
|
248
|
+
const audienceNote = computed(() => {
|
|
249
|
+
if (audienceTabs.value.emptied) {
|
|
250
|
+
return "This reader is not set to serve anybody. Switch an audience on in the reader's settings before granting access here.";
|
|
251
|
+
}
|
|
252
|
+
if (audienceTabs.value.narrowed) {
|
|
253
|
+
return "Only the kinds of people this reader serves are shown. The rest are switched off in the reader's settings.";
|
|
254
|
+
}
|
|
255
|
+
return "";
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const currentTab = computed(
|
|
259
|
+
() => visibleTabs.value.find((tab) => tab.value === activeTab.value)
|
|
260
|
+
?? visibleTabs.value[0]
|
|
261
|
+
?? TABS[0],
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
/** Granted counts per category, for the tab strip. One call, all three. */
|
|
265
|
+
const counts = ref<Record<string, number>>({});
|
|
266
|
+
const tabCount = (tab: (typeof TABS)[number]) => counts.value[tab.category] ?? 0;
|
|
267
|
+
|
|
268
|
+
/* Name over unit in one cell rather than a "Unit" column — see `subtitleOf`.
|
|
269
|
+
The first column follows the tab; "Door access" is the same action for all. */
|
|
270
|
+
const headers = computed(() => [
|
|
271
|
+
{ title: currentTab.value.noun, value: "name", sortable: false },
|
|
162
272
|
{ title: "Door access", value: "selected", sortable: false, width: 190 },
|
|
163
|
-
];
|
|
273
|
+
]);
|
|
164
274
|
|
|
165
275
|
const search = ref("");
|
|
166
276
|
const page = ref(1);
|
|
@@ -183,10 +293,17 @@ const open = computed({
|
|
|
183
293
|
const readerId = computed(() => String(props.reader?._id ?? ""));
|
|
184
294
|
const readerLocation = computed(() => String(props.reader?.location ?? "").trim());
|
|
185
295
|
const readerLabel = computed(() => {
|
|
186
|
-
const name = String(props.reader?.name ?? "").trim() || "HID
|
|
296
|
+
const name = String(props.reader?.name ?? "").trim() || "HID Reader";
|
|
187
297
|
return readerLocation.value ? `${name} · ${readerLocation.value}` : name;
|
|
188
298
|
});
|
|
189
299
|
|
|
300
|
+
/* "unit" only means something for a resident. */
|
|
301
|
+
const searchPlaceholder = computed(() =>
|
|
302
|
+
activeTab.value === "resident"
|
|
303
|
+
? "Search name, unit or registration"
|
|
304
|
+
: "Search name or registration",
|
|
305
|
+
);
|
|
306
|
+
|
|
190
307
|
const assignedTotal = computed(() => draft.value.size);
|
|
191
308
|
const dirty = computed(() => hasChanges(savedState.value, draft.value));
|
|
192
309
|
/* The draft is the truth once the dialog is open, so `selected` is recomputed
|
|
@@ -199,6 +316,10 @@ watch(
|
|
|
199
316
|
if (!isOpen || !readerId.value) return;
|
|
200
317
|
search.value = "";
|
|
201
318
|
page.value = 1;
|
|
319
|
+
// The first tab this reader actually serves, not always Resident: opening on
|
|
320
|
+
// one it does not serve would draw an empty list under a heading that cannot
|
|
321
|
+
// accept a grant.
|
|
322
|
+
activeTab.value = visibleTabs.value[0]?.value ?? "resident";
|
|
202
323
|
void loadAll();
|
|
203
324
|
},
|
|
204
325
|
{ immediate: true },
|
|
@@ -221,6 +342,13 @@ async function loadAssignments() {
|
|
|
221
342
|
const assignments = response?.data?.assignments ?? [];
|
|
222
343
|
savedState.value = seedAssignments(assignments);
|
|
223
344
|
draft.value = seedAssignments(assignments);
|
|
345
|
+
/*
|
|
346
|
+
* Counted from the assignments themselves rather than the server's `counts`
|
|
347
|
+
* block, so the strip follows the DRAFT and a tick updates the number it
|
|
348
|
+
* sits beside. `countByCategory` is the same helper the candidate screens
|
|
349
|
+
* use.
|
|
350
|
+
*/
|
|
351
|
+
counts.value = countByCategory(draft.value);
|
|
224
352
|
} catch (error) {
|
|
225
353
|
showToast(getErrorMessage(error, "Unable to load this reader's permissions."), "error");
|
|
226
354
|
}
|
|
@@ -230,27 +358,6 @@ function toRecord(value: unknown): Record<string, unknown> {
|
|
|
230
358
|
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
|
231
359
|
}
|
|
232
360
|
|
|
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
|
-
|
|
254
361
|
/**
|
|
255
362
|
* Why a row cannot be switched, in words the reader of the screen can act on.
|
|
256
363
|
*
|
|
@@ -294,10 +401,10 @@ async function loadEnrolled() {
|
|
|
294
401
|
page: page.value,
|
|
295
402
|
limit: 10,
|
|
296
403
|
search: search.value.trim(),
|
|
297
|
-
//
|
|
298
|
-
// count comes back from the same query, so narrowing the list on
|
|
299
|
-
// would print a total that never matched the rows under it.
|
|
300
|
-
type:
|
|
404
|
+
// THIS TAB's people, and filtered by the SERVER rather than here. The
|
|
405
|
+
// pager's count comes back from the same query, so narrowing the list on
|
|
406
|
+
// this side would print a total that never matched the rows under it.
|
|
407
|
+
type: currentTab.value.type,
|
|
301
408
|
});
|
|
302
409
|
const result = readCandidatePage(response);
|
|
303
410
|
/*
|
|
@@ -309,11 +416,13 @@ async function loadEnrolled() {
|
|
|
309
416
|
*/
|
|
310
417
|
enrolled.value = result.items.map((row) => {
|
|
311
418
|
const identity = toRecord(row);
|
|
312
|
-
const subject =
|
|
419
|
+
const subject = subjectOfIdentity(identity);
|
|
313
420
|
const metadata = toRecord(identity.metadata);
|
|
314
421
|
return {
|
|
315
422
|
subjectId: subject?.subjectId ?? "",
|
|
316
|
-
|
|
423
|
+
// The record's own category where it has one; otherwise the tab's,
|
|
424
|
+
// which is the only thing that knows what the operator means.
|
|
425
|
+
category: subject?.category ?? currentTab.value.category,
|
|
317
426
|
// The row's own key. `subjectId` is empty for the rows below, and two
|
|
318
427
|
// empty keys would collapse into one row in the table.
|
|
319
428
|
rowKey: String(identity._id ?? identity.hidUserId ?? Math.random()),
|
|
@@ -342,6 +451,24 @@ async function loadEnrolled() {
|
|
|
342
451
|
}
|
|
343
452
|
}
|
|
344
453
|
|
|
454
|
+
/**
|
|
455
|
+
* Switching tabs refetches, because the filter is the SERVER's.
|
|
456
|
+
*
|
|
457
|
+
* The draft is untouched: it is the whole reader's, and the save sends all of it
|
|
458
|
+
* whatever tab is in front of you. Clearing it here - or building the payload
|
|
459
|
+
* from the loaded rows - is what would revoke everybody on the other two tabs.
|
|
460
|
+
*
|
|
461
|
+
* Search is cleared with the tab. It is a server-side search within one type, so
|
|
462
|
+
* carrying it across would land on an empty list and look like the tab was empty.
|
|
463
|
+
*/
|
|
464
|
+
function selectTab(value: (typeof TABS)[number]["value"]) {
|
|
465
|
+
if (activeTab.value === value) return;
|
|
466
|
+
activeTab.value = value;
|
|
467
|
+
search.value = "";
|
|
468
|
+
page.value = 1;
|
|
469
|
+
void loadEnrolled();
|
|
470
|
+
}
|
|
471
|
+
|
|
345
472
|
function goToPage(value: number) {
|
|
346
473
|
page.value = Number(value) || 1;
|
|
347
474
|
void loadEnrolled();
|
|
@@ -352,15 +479,34 @@ function searchFromFirstPage() {
|
|
|
352
479
|
void loadEnrolled();
|
|
353
480
|
}
|
|
354
481
|
|
|
482
|
+
/**
|
|
483
|
+
* Grant or revoke one person.
|
|
484
|
+
*
|
|
485
|
+
* The category comes from the row where the server gave it one, and otherwise
|
|
486
|
+
* from the TAB - never from re-reading the identity's links on this side. A
|
|
487
|
+
* contractor and a property-management member are both a `member` link, and the
|
|
488
|
+
* stale local copy of that reading is what used to file contractors as staff: the
|
|
489
|
+
* reconcile then derived `staff` from that category and rewrote the person's
|
|
490
|
+
* type, moving them into the Member tab.
|
|
491
|
+
*
|
|
492
|
+
* An ALREADY GRANTED person keeps the category their assignment holds, so merely
|
|
493
|
+
* visiting a tab and toggling somebody off and on cannot re-file them.
|
|
494
|
+
*/
|
|
355
495
|
function toggle(item: THidPermissionCandidate, selected: boolean) {
|
|
356
|
-
|
|
496
|
+
const subjectId = String(item.subjectId ?? "");
|
|
497
|
+
if (!subjectId) return;
|
|
498
|
+
const category = savedState.value.get(subjectId)?.category
|
|
499
|
+
?? item.category
|
|
500
|
+
?? currentTab.value.category;
|
|
357
501
|
draft.value = setAssignment(
|
|
358
502
|
draft.value,
|
|
359
503
|
savedState.value,
|
|
360
|
-
|
|
361
|
-
|
|
504
|
+
subjectId,
|
|
505
|
+
category,
|
|
362
506
|
selected,
|
|
363
507
|
);
|
|
508
|
+
// The strip sits beside the rows, so it follows the tick rather than the save.
|
|
509
|
+
counts.value = countByCategory(draft.value);
|
|
364
510
|
}
|
|
365
511
|
|
|
366
512
|
async function save() {
|
|
@@ -401,6 +547,22 @@ function getErrorMessage(error: unknown, fallback: string) {
|
|
|
401
547
|
</script>
|
|
402
548
|
|
|
403
549
|
<style scoped lang="scss">
|
|
550
|
+
/* The granted count beside a tab label. Quieter than the label and set in
|
|
551
|
+
tabular figures so the three tabs do not shift width as numbers change. */
|
|
552
|
+
/* Quieter than the intro above it, and set apart so it reads as a condition of
|
|
553
|
+
this reader rather than part of the screen's own description. */
|
|
554
|
+
.hid-access-permissions__audience-note {
|
|
555
|
+
margin-top: 6px;
|
|
556
|
+
font-style: italic;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
.hid-access-permissions__tab-count {
|
|
560
|
+
margin-left: 6px;
|
|
561
|
+
color: var(--muted);
|
|
562
|
+
font-weight: 600;
|
|
563
|
+
font-variant-numeric: tabular-nums;
|
|
564
|
+
}
|
|
565
|
+
|
|
404
566
|
.hid-access-permissions {
|
|
405
567
|
&__title { padding-block: 16px; }
|
|
406
568
|
|
|
@@ -22,16 +22,14 @@
|
|
|
22
22
|
denial here would flash "no permission" at the owner on every load. -->
|
|
23
23
|
<template v-if="!isAppRoleResolved" />
|
|
24
24
|
|
|
25
|
-
<slot v-else-if="hidEnabled &&
|
|
25
|
+
<slot v-else-if="hidEnabled && permitted" />
|
|
26
26
|
|
|
27
27
|
<div v-else-if="hidEnabled" class="screen-empty hid-gate">
|
|
28
28
|
<span class="hid-gate__icon tone-info">
|
|
29
29
|
<v-icon icon="mdi-lock-outline" size="24" />
|
|
30
30
|
</span>
|
|
31
|
-
<h2 class="hid-gate__title">
|
|
32
|
-
<p class="hid-gate__body">
|
|
33
|
-
Ask an administrator to grant your role access card management for this site.
|
|
34
|
-
</p>
|
|
31
|
+
<h2 class="hid-gate__title">{{ deniedTitle }}</h2>
|
|
32
|
+
<p class="hid-gate__body">{{ deniedBody }}</p>
|
|
35
33
|
</div>
|
|
36
34
|
|
|
37
35
|
<div v-else class="screen-empty hid-gate">
|
|
@@ -50,10 +48,18 @@ const props = withDefaults(
|
|
|
50
48
|
org: string;
|
|
51
49
|
message?: string;
|
|
52
50
|
redirectToSettings?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Which HID right opens the screen behind this gate. `view` is the admin
|
|
53
|
+
* screens (readers, users, cards, intercom, access logs: `canViewHid`);
|
|
54
|
+
* `use` is the member's own HID QR Code page (`canUseHid`). Defaults to the
|
|
55
|
+
* stricter one, so a page that forgets to say is an admin page.
|
|
56
|
+
*/
|
|
57
|
+
level?: "view" | "use";
|
|
53
58
|
}>(),
|
|
54
59
|
{
|
|
55
60
|
message: "Enable HID as a service for this site before accessing this screen.",
|
|
56
61
|
redirectToSettings: true,
|
|
62
|
+
level: "view",
|
|
57
63
|
},
|
|
58
64
|
);
|
|
59
65
|
|
|
@@ -61,13 +67,26 @@ const { getSiteById } = useSiteSettings();
|
|
|
61
67
|
const { getHidServiceEnabled } = useOptionalServices();
|
|
62
68
|
|
|
63
69
|
// The permission half of the same gate. This component is the only thing all
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
// guard per screen. See `composables/useHidAccessPermission.ts`
|
|
67
|
-
//
|
|
68
|
-
const {
|
|
70
|
+
// seven `access-mgmt` HID pages have in common, so putting the check here
|
|
71
|
+
// governs all of them, and every inherited route across the eleven apps,
|
|
72
|
+
// without a page guard per screen. See `composables/useHidAccessPermission.ts`
|
|
73
|
+
// for the five flags and the alias period.
|
|
74
|
+
const { canViewHid, canUseHid } = useHidAccessPermission();
|
|
69
75
|
const { isAppRoleResolved } = useLocalSetup();
|
|
70
76
|
|
|
77
|
+
const permitted = computed(() => (props.level === "use" ? canUseHid.value : canViewHid.value));
|
|
78
|
+
|
|
79
|
+
const deniedTitle = computed(() =>
|
|
80
|
+
props.level === "use"
|
|
81
|
+
? "You do not have permission for HID access"
|
|
82
|
+
: "You do not have permission for HID access management",
|
|
83
|
+
);
|
|
84
|
+
const deniedBody = computed(() =>
|
|
85
|
+
props.level === "use"
|
|
86
|
+
? "Ask an administrator to grant your role HID access for this site."
|
|
87
|
+
: "Ask an administrator to grant your role HID management for this site.",
|
|
88
|
+
);
|
|
89
|
+
|
|
71
90
|
// Do not await this request during route setup. Every HID page uses this gate;
|
|
72
91
|
// awaiting it makes Nuxt Suspense keep rendering the previous page until the
|
|
73
92
|
// site request finishes, so the URL changes while the visible module appears
|
|
@@ -46,16 +46,18 @@
|
|
|
46
46
|
@update:page="page = $event"
|
|
47
47
|
>
|
|
48
48
|
<!-- The action swaps with the tab, as it did in the old top bar. -->
|
|
49
|
+
<!-- Contacts are reader objects, so adding one is the readers right.
|
|
50
|
+
The web call is a member level call and stays for everyone. -->
|
|
49
51
|
<template #actions>
|
|
50
52
|
<AppButton
|
|
51
|
-
v-if="activeTab === 'contacts'"
|
|
53
|
+
v-if="activeTab === 'contacts' && canManageHidReaders"
|
|
52
54
|
icon="mdi-plus"
|
|
53
55
|
@click="openContactDialog()"
|
|
54
56
|
>
|
|
55
57
|
Add Contacts
|
|
56
58
|
</AppButton>
|
|
57
59
|
<AppButton
|
|
58
|
-
v-else
|
|
60
|
+
v-else-if="activeTab !== 'contacts'"
|
|
59
61
|
icon="mdi-phone-in-talk-outline"
|
|
60
62
|
class="web-call-button"
|
|
61
63
|
:class="webPhoneButtonClass"
|
|
@@ -151,23 +153,30 @@
|
|
|
151
153
|
<template #activator="{ props: menuProps }">
|
|
152
154
|
<AppButton v-bind="menuProps" variant="row" icon="mdi-dots-vertical" />
|
|
153
155
|
</template>
|
|
156
|
+
<!-- The reader's SIP settings, call settings, dial code and contacts
|
|
157
|
+
are reader configuration: the readers right. View and the status
|
|
158
|
+
check stay for the view right. -->
|
|
154
159
|
<v-list v-if="activeTab === 'intercom'" density="compact" class="screen-menu">
|
|
155
160
|
<v-list-item class="screen-menu__item" title="View" prepend-icon="mdi-eye" @click="openViewDialog(item)" />
|
|
156
|
-
<v-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
161
|
+
<template v-if="canManageHidReaders">
|
|
162
|
+
<v-list-item
|
|
163
|
+
class="screen-menu__item"
|
|
164
|
+
title="Test Connection with SIP"
|
|
165
|
+
prepend-icon="mdi-lan-check"
|
|
166
|
+
:disabled="testingSipReaderId === item.reader._id"
|
|
167
|
+
@click="testSipConnection(item)"
|
|
168
|
+
/>
|
|
169
|
+
<v-list-item class="screen-menu__item" title="Call Settings" prepend-icon="mdi-cog" @click="openCallDialog(item)" />
|
|
170
|
+
<v-list-item class="screen-menu__item" title="Dial Code" prepend-icon="mdi-dialpad" @click="openDialCodeDialog(item)" />
|
|
171
|
+
</template>
|
|
165
172
|
</v-list>
|
|
166
173
|
<v-list v-else density="compact" class="screen-menu">
|
|
167
174
|
<v-list-item class="screen-menu__item" title="Check reader SIP status" prepend-icon="mdi-lan-check" @click="testContactSipStatus(item)" />
|
|
168
175
|
<v-list-item class="screen-menu__item" title="View" prepend-icon="mdi-eye" @click="openContactView(item)" />
|
|
169
|
-
<v-
|
|
170
|
-
|
|
176
|
+
<template v-if="canManageHidReaders">
|
|
177
|
+
<v-list-item class="screen-menu__item" title="Edit" prepend-icon="mdi-pencil" @click="openContactDialog(item)" />
|
|
178
|
+
<v-list-item class="screen-menu__item screen-menu__item--danger" title="Delete" prepend-icon="mdi-delete-outline" @click="openDeleteContact(item)" />
|
|
179
|
+
</template>
|
|
171
180
|
</v-list>
|
|
172
181
|
</v-menu>
|
|
173
182
|
</template>
|
|
@@ -749,6 +758,10 @@ type IntercomRow = {
|
|
|
749
758
|
pjsip: Record<string, unknown>;
|
|
750
759
|
};
|
|
751
760
|
|
|
761
|
+
// Reader configuration (SIP, call settings, dial code, contacts) is the readers
|
|
762
|
+
// right. The screen itself is behind `HidEnabledGate` (the view right).
|
|
763
|
+
const { canManageHidReaders } = useHidAccessPermission();
|
|
764
|
+
|
|
752
765
|
const {
|
|
753
766
|
getReaders,
|
|
754
767
|
getReaderConfiguration,
|
|
@@ -121,7 +121,7 @@ import type { HidProfileQr } from "../composables/useHidAmico";
|
|
|
121
121
|
|
|
122
122
|
const props = defineProps<{ site: string }>();
|
|
123
123
|
|
|
124
|
-
const {
|
|
124
|
+
const { getEnrollmentReaders, getProfileQr, issueProfileQr, deleteProfileQr } = useHidAmico();
|
|
125
125
|
|
|
126
126
|
type HidReaderOption = { _id: string; name?: string; portalName?: string };
|
|
127
127
|
|
|
@@ -200,7 +200,11 @@ function downloadQr() {
|
|
|
200
200
|
|
|
201
201
|
async function loadReaders() {
|
|
202
202
|
if (!props.site) return;
|
|
203
|
-
|
|
203
|
+
/* The self service list, not the admin one. `GET /readers` sits behind the
|
|
204
|
+
HID view right, so a member holding only `hid-mgmt:use-hid-access` (the
|
|
205
|
+
right this page is drawn for) was refused before the picker could fill.
|
|
206
|
+
`enrollment-readers` is the same site's enabled readers, for members. */
|
|
207
|
+
const response = toRecord(await getEnrollmentReaders(props.site));
|
|
204
208
|
const data = toRecord(response.data);
|
|
205
209
|
const items = response.items ?? data.items ?? data.readers ?? [];
|
|
206
210
|
readers.value = (Array.isArray(items) ? items : []).filter((reader) => reader?._id);
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
HID QR CODE CONFIGURATION - the reader's own settings, inside Site Settings.
|
|
3
3
|
|
|
4
|
-
It is mounted in `SiteSettings.vue`'s "HID
|
|
4
|
+
It is mounted in `SiteSettings.vue`'s "HID Reader" expansion panel, next
|
|
5
5
|
to `HidServiceSettingsPanel`, so it takes the shape the handoff gives a
|
|
6
6
|
settings module BODY: section labels at 13px/700, explanatory lines at
|
|
7
7
|
12-13px/600 in `--muted`, the 38x22 toggle, 40px fields and 38px buttons.
|
|
8
8
|
|
|
9
9
|
Two pieces of legacy chrome are gone rather than restyled:
|
|
10
10
|
* the `#082e43` navy title bar. The design puts NO card inside an accordion
|
|
11
|
-
body - the accordion head ("HID
|
|
11
|
+
body - the accordion head ("HID Reader") is the card, and a second
|
|
12
12
|
titled panel inside it is a header the design does not draw. Its title is
|
|
13
13
|
kept as the body's first section label.
|
|
14
14
|
* the chevron button that sat on that bar. It had no `@click` and no model:
|