@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
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHO A READER IS FOR — the frontend's reading of it.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `hid-reader-audience.util.ts` in core, which is the authority and the
|
|
5
|
+
* only thing that enforces it. This repository cannot import core's copy, so
|
|
6
|
+
* there is one here — and it lives in a util with tests rather than inside a
|
|
7
|
+
* component, because the DEFAULT is the part that can take a door offline.
|
|
8
|
+
*
|
|
9
|
+
* A site with several doors rarely wants every door open to everybody: a service
|
|
10
|
+
* entrance for contractors, a lobby for residents. Declaring that on the reader
|
|
11
|
+
* is what lets an enrolment work out which readers a person belongs on.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const HID_READER_AUDIENCES = [
|
|
15
|
+
"resident",
|
|
16
|
+
"residentVisitor",
|
|
17
|
+
"member",
|
|
18
|
+
"serviceProvider",
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
export type THidReaderAudience = (typeof HID_READER_AUDIENCES)[number];
|
|
22
|
+
export type THidReaderAudiences = Record<THidReaderAudience, boolean>;
|
|
23
|
+
|
|
24
|
+
/** What each switch is called, and what it means, on the reader form. */
|
|
25
|
+
export const HID_READER_AUDIENCE_FIELDS: ReadonlyArray<{
|
|
26
|
+
value: THidReaderAudience;
|
|
27
|
+
label: string;
|
|
28
|
+
hint: string;
|
|
29
|
+
}> = [
|
|
30
|
+
{
|
|
31
|
+
value: "resident",
|
|
32
|
+
label: "Resident",
|
|
33
|
+
hint: "Residents of this site may be enrolled on this reader.",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
value: "residentVisitor",
|
|
37
|
+
label: "Resident visitor",
|
|
38
|
+
hint: "A resident may issue a visitor QR pass for this reader.",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
value: "member",
|
|
42
|
+
label: "Member",
|
|
43
|
+
hint: "Property management staff may be enrolled on this reader.",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
value: "serviceProvider",
|
|
47
|
+
label: "Service provider",
|
|
48
|
+
hint: "A service provider's people may be enrolled on this reader.",
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* ABSENT MEANS ALL FOUR, and that is the whole backward-compatibility story.
|
|
54
|
+
*
|
|
55
|
+
* Every reader registered before this field existed has no `audiences`. Reading
|
|
56
|
+
* that as "serves nobody" would show every existing reader as closed to
|
|
57
|
+
* everyone, and - worse - a save from that screen would then write those
|
|
58
|
+
* falsehoods and actually close them.
|
|
59
|
+
*
|
|
60
|
+
* A field that is present but missing a key is read the same way, so a partial
|
|
61
|
+
* record cannot quietly close a door either. Only a real `false` closes one.
|
|
62
|
+
*/
|
|
63
|
+
export function readerAudiences(
|
|
64
|
+
reader: Record<string, unknown> | null | undefined,
|
|
65
|
+
): THidReaderAudiences {
|
|
66
|
+
const stored = reader?.audiences;
|
|
67
|
+
const record = stored && typeof stored === "object"
|
|
68
|
+
? stored as Record<string, unknown>
|
|
69
|
+
: {};
|
|
70
|
+
return HID_READER_AUDIENCES.reduce((audiences, audience) => ({
|
|
71
|
+
...audiences,
|
|
72
|
+
[audience]: record[audience] === undefined ? true : record[audience] === true,
|
|
73
|
+
}), {} as THidReaderAudiences);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Does this reader serve that audience? */
|
|
77
|
+
export function readerServesAudience(
|
|
78
|
+
reader: Record<string, unknown> | null | undefined,
|
|
79
|
+
audience: THidReaderAudience,
|
|
80
|
+
): boolean {
|
|
81
|
+
return readerAudiences(reader)[audience] === true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The audience a permission category answers to.
|
|
86
|
+
*
|
|
87
|
+
* Both service-provider categories share one switch: a property manager thinks
|
|
88
|
+
* "may contractors use this door", not "may contractor companies use it as
|
|
89
|
+
* distinct from contractor people". Unrecognised returns null, so a category
|
|
90
|
+
* added later is visibly unmapped rather than silently lumped in with one.
|
|
91
|
+
*/
|
|
92
|
+
export function audienceOfCategory(category: unknown): THidReaderAudience | null {
|
|
93
|
+
switch (String(category ?? "")) {
|
|
94
|
+
case "resident":
|
|
95
|
+
return "resident";
|
|
96
|
+
case "property_management":
|
|
97
|
+
return "member";
|
|
98
|
+
case "service_provider":
|
|
99
|
+
case "service_provider_member":
|
|
100
|
+
return "serviceProvider";
|
|
101
|
+
default:
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Which of the Access Permissions tabs this reader is worth showing.
|
|
108
|
+
*
|
|
109
|
+
* A reader that serves nobody in a category cannot hold a grant in it - core
|
|
110
|
+
* refuses the save - so a tab for it would be a list the operator can look at
|
|
111
|
+
* and never act on.
|
|
112
|
+
*
|
|
113
|
+
* NEVER RETURNS NOTHING. A reader with every audience switched off would leave a
|
|
114
|
+
* dialog with no tabs and no explanation, which reads as a broken screen; the
|
|
115
|
+
* first tab is kept so there is always something to show, and it will simply be
|
|
116
|
+
* empty. The caller is told whether that happened so it can say why.
|
|
117
|
+
*/
|
|
118
|
+
export function visibleAudienceTabs<T extends { category: unknown }>(
|
|
119
|
+
reader: Record<string, unknown> | null | undefined,
|
|
120
|
+
tabs: readonly T[],
|
|
121
|
+
): { tabs: T[]; narrowed: boolean; emptied: boolean } {
|
|
122
|
+
const served = tabs.filter((tab) => {
|
|
123
|
+
const audience = audienceOfCategory(tab.category);
|
|
124
|
+
return audience ? readerServesAudience(reader, audience) : false;
|
|
125
|
+
});
|
|
126
|
+
if (!served.length) {
|
|
127
|
+
return { tabs: tabs.slice(0, 1), narrowed: true, emptied: true };
|
|
128
|
+
}
|
|
129
|
+
return { tabs: served, narrowed: served.length !== tabs.length, emptied: false };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* WHICH SUBJECT TYPES CAN BE ENROLLED on the readers being targeted.
|
|
134
|
+
*
|
|
135
|
+
* Takes a SET of readers rather than one, deliberately. Today an enrolment goes
|
|
136
|
+
* to the reader selected on the screen, so the set has one member; once
|
|
137
|
+
* enrolment fans out to every reader whose audiences match, the same question is
|
|
138
|
+
* asked of several. Shaping it this way now means the call site changes later
|
|
139
|
+
* and this does not.
|
|
140
|
+
*
|
|
141
|
+
* Offering a type the reader will not accept is not merely untidy: the server
|
|
142
|
+
* refuses the grant, and it refuses it on SAVE - after the operator has chosen a
|
|
143
|
+
* person and captured a face, which on a reader is the expensive step.
|
|
144
|
+
*
|
|
145
|
+
* `category` on each option is the dropdown's own value, mapped to a permission
|
|
146
|
+
* category and then to an audience by composing the two functions that already
|
|
147
|
+
* exist, rather than by a third mapping that could drift from them.
|
|
148
|
+
*/
|
|
149
|
+
export function availableEnrolmentSubjects<T extends { value: unknown }>(
|
|
150
|
+
readers: ReadonlyArray<Record<string, unknown> | null | undefined>,
|
|
151
|
+
options: readonly T[],
|
|
152
|
+
categoryOfOption: (option: T) => unknown,
|
|
153
|
+
): { options: T[]; narrowed: boolean; emptied: boolean } {
|
|
154
|
+
/*
|
|
155
|
+
* NO READER YET IS NOT "NO AUDIENCES". The dialog can be mounted before a
|
|
156
|
+
* reader has been chosen, and showing nothing then would read as a reader that
|
|
157
|
+
* serves nobody rather than as a question not yet answered.
|
|
158
|
+
*/
|
|
159
|
+
const known = readers.filter(Boolean) as Array<Record<string, unknown>>;
|
|
160
|
+
if (!known.length) {
|
|
161
|
+
return { options: [...options], narrowed: false, emptied: false };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const served = options.filter((option) => {
|
|
165
|
+
const audience = audienceOfCategory(categoryOfOption(option));
|
|
166
|
+
if (!audience) return false;
|
|
167
|
+
// ANY of the targeted readers is enough: the enrolment lands on whichever
|
|
168
|
+
// of them serve this person, not on all of them.
|
|
169
|
+
return known.some((reader) => readerServesAudience(reader, audience));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
options: served,
|
|
174
|
+
narrowed: served.length !== options.length,
|
|
175
|
+
// Nothing offered at all. The caller says why rather than drawing an empty
|
|
176
|
+
// dropdown, which looks like a screen that failed to load.
|
|
177
|
+
emptied: !served.length,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* ── WHAT SWITCHING ONE OFF COSTS ──────────────────────────────────────── */
|
|
182
|
+
|
|
183
|
+
/** `counts` as `getSitePermissions` returns it, per category. */
|
|
184
|
+
export type THidPermissionCounts = {
|
|
185
|
+
resident?: number;
|
|
186
|
+
propertyManagement?: number;
|
|
187
|
+
serviceProvider?: number;
|
|
188
|
+
serviceProviderMember?: number;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* How many people on this reader lose access if an account type is switched off.
|
|
193
|
+
*
|
|
194
|
+
* Both service-provider categories answer to the one switch, so both are
|
|
195
|
+
* counted - a company grant and a named contractor are the same door to the
|
|
196
|
+
* person walking through it.
|
|
197
|
+
*
|
|
198
|
+
* RESIDENT VISITOR IS ALWAYS ZERO, and that is not an oversight. A visitor pass
|
|
199
|
+
* is not a permission assignment: it is bound through `user_access_rules` with
|
|
200
|
+
* its own expiry, so switching the account type off stops new passes being
|
|
201
|
+
* issued and leaves any already issued to run out on their own. Reporting a
|
|
202
|
+
* number there would promise a revocation that does not happen.
|
|
203
|
+
*/
|
|
204
|
+
export function peopleLosingAccess(
|
|
205
|
+
audience: THidReaderAudience,
|
|
206
|
+
counts: THidPermissionCounts | null | undefined,
|
|
207
|
+
): number {
|
|
208
|
+
const read = (value: unknown) => {
|
|
209
|
+
const count = Number(value ?? 0);
|
|
210
|
+
return Number.isFinite(count) && count > 0 ? Math.floor(count) : 0;
|
|
211
|
+
};
|
|
212
|
+
switch (audience) {
|
|
213
|
+
case "resident":
|
|
214
|
+
return read(counts?.resident);
|
|
215
|
+
case "member":
|
|
216
|
+
return read(counts?.propertyManagement);
|
|
217
|
+
case "serviceProvider":
|
|
218
|
+
return read(counts?.serviceProvider) + read(counts?.serviceProviderMember);
|
|
219
|
+
case "residentVisitor":
|
|
220
|
+
default:
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The account types being switched OFF by this edit, with what each one costs.
|
|
227
|
+
*
|
|
228
|
+
* Only on-to-off transitions. An account type that was already off costs
|
|
229
|
+
* nothing to leave off, and listing it would make every save of an unrelated
|
|
230
|
+
* field look like a revocation.
|
|
231
|
+
*/
|
|
232
|
+
export function audienceRevocations(
|
|
233
|
+
before: THidReaderAudiences,
|
|
234
|
+
after: THidReaderAudiences,
|
|
235
|
+
counts: THidPermissionCounts | null | undefined,
|
|
236
|
+
): Array<{ audience: THidReaderAudience; label: string; people: number }> {
|
|
237
|
+
return HID_READER_AUDIENCE_FIELDS
|
|
238
|
+
.filter((field) => before[field.value] === true && after[field.value] === false)
|
|
239
|
+
.map((field) => ({
|
|
240
|
+
audience: field.value,
|
|
241
|
+
label: field.label,
|
|
242
|
+
people: peopleLosingAccess(field.value, counts),
|
|
243
|
+
}));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Account types this edit would leave with NO reader at the site.
|
|
248
|
+
*
|
|
249
|
+
* A warning rather than a refusal: a site may genuinely have no door for
|
|
250
|
+
* contractors, and the last reader is sometimes switched off because it is being
|
|
251
|
+
* decommissioned. But doing it unknowingly means residents - or a whole category
|
|
252
|
+
* of people - quietly have no way in, and nothing else on the screen would say so.
|
|
253
|
+
*
|
|
254
|
+
* The reader being edited is judged by `after`, not by what is stored, so the
|
|
255
|
+
* warning describes the save about to happen rather than the state before it.
|
|
256
|
+
*/
|
|
257
|
+
export function audiencesLeftWithNoReader(
|
|
258
|
+
readerId: unknown,
|
|
259
|
+
after: THidReaderAudiences,
|
|
260
|
+
siteReaders: ReadonlyArray<Record<string, unknown>>,
|
|
261
|
+
): Array<{ audience: THidReaderAudience; label: string }> {
|
|
262
|
+
const editing = String(readerId ?? "").trim();
|
|
263
|
+
return HID_READER_AUDIENCE_FIELDS
|
|
264
|
+
.filter((field) => {
|
|
265
|
+
const servedElsewhere = siteReaders.some((reader) => {
|
|
266
|
+
const id = String(reader?._id ?? "").trim();
|
|
267
|
+
// The reader being edited is judged by the pending values, not the stored
|
|
268
|
+
// ones - otherwise saving its own switch-off would never warn.
|
|
269
|
+
if (editing && id === editing) return false;
|
|
270
|
+
return readerServesAudience(reader, field.value);
|
|
271
|
+
});
|
|
272
|
+
return !servedElsewhere && after[field.value] === false;
|
|
273
|
+
})
|
|
274
|
+
.map((field) => ({ audience: field.value, label: field.label }));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/* ── WHICH READERS AN ENROLMENT LANDS ON ───────────────────────────────── */
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* One reader's standing as a target for enrolling a person of some category.
|
|
281
|
+
*
|
|
282
|
+
* `reason` is what to tell the operator about a reader left out; `warning` what
|
|
283
|
+
* to tell them about one included that may not answer. Sentences, not codes —
|
|
284
|
+
* the only consumer is a list somebody reads before pressing Save.
|
|
285
|
+
*/
|
|
286
|
+
export type THidEnrolmentTarget = {
|
|
287
|
+
readerId: string;
|
|
288
|
+
name: string;
|
|
289
|
+
location: string;
|
|
290
|
+
included: boolean;
|
|
291
|
+
reason?: string;
|
|
292
|
+
warning?: string;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* SPLIT A SITE'S READERS INTO THE ONES THIS PERSON BELONGS ON AND THE REST.
|
|
297
|
+
*
|
|
298
|
+
* Mirrors `partitionEnrolmentTargets` in core, which is the authority — core
|
|
299
|
+
* refuses a grant on a reader that does not serve the category, so this only
|
|
300
|
+
* decides what to draw. The copy exists because layer-common cannot import it.
|
|
301
|
+
*
|
|
302
|
+
* Three conditions, checked in the order that produces the most useful sentence
|
|
303
|
+
* rather than the order they are stored in. A reader that is switched off should
|
|
304
|
+
* say so; telling the operator it "does not serve residents" would be true of
|
|
305
|
+
* nothing and send them to the audience switches instead of the enable switch.
|
|
306
|
+
*
|
|
307
|
+
* AN OFFLINE READER IS STILL A TARGET. `offline` means we have not heard from
|
|
308
|
+
* it, not that it is unconfigured: the access grant is a database write that
|
|
309
|
+
* succeeds and the device write is retried. Excluding it would quietly deny
|
|
310
|
+
* somebody a door because a reader was asleep.
|
|
311
|
+
*/
|
|
312
|
+
export function partitionEnrolmentTargets(
|
|
313
|
+
readers: ReadonlyArray<Record<string, unknown> | null | undefined>,
|
|
314
|
+
category: unknown,
|
|
315
|
+
): { targets: THidEnrolmentTarget[]; excluded: THidEnrolmentTarget[] } {
|
|
316
|
+
const targets: THidEnrolmentTarget[] = [];
|
|
317
|
+
const excluded: THidEnrolmentTarget[] = [];
|
|
318
|
+
|
|
319
|
+
for (const reader of readers) {
|
|
320
|
+
if (!reader) continue;
|
|
321
|
+
const readerId = String(reader._id ?? "").trim();
|
|
322
|
+
if (!readerId) continue;
|
|
323
|
+
const base = {
|
|
324
|
+
readerId,
|
|
325
|
+
name: String(reader.name ?? "").trim() || "HID Reader",
|
|
326
|
+
location: String(reader.location ?? "").trim(),
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const status = String(reader.status ?? "");
|
|
330
|
+
if (reader.enabled === false || status === "inactive" || status === "deleted") {
|
|
331
|
+
excluded.push({ ...base, included: false, reason: "This reader is switched off." });
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (!reader.portalId || !String(reader.portalName ?? "").trim()) {
|
|
335
|
+
excluded.push({
|
|
336
|
+
...base,
|
|
337
|
+
included: false,
|
|
338
|
+
reason: "No access portal is selected on this reader.",
|
|
339
|
+
});
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const audience = audienceOfCategory(category);
|
|
343
|
+
if (!audience) {
|
|
344
|
+
excluded.push({
|
|
345
|
+
...base,
|
|
346
|
+
included: false,
|
|
347
|
+
reason: "This kind of person cannot be granted a reader.",
|
|
348
|
+
});
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (!readerServesAudience(reader, audience)) {
|
|
352
|
+
excluded.push({
|
|
353
|
+
...base,
|
|
354
|
+
included: false,
|
|
355
|
+
reason: `This reader does not serve ${audienceLabel(audience)}.`,
|
|
356
|
+
});
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
targets.push({
|
|
361
|
+
...base,
|
|
362
|
+
included: true,
|
|
363
|
+
...(status === "offline"
|
|
364
|
+
? { warning: "Not heard from recently. Access is granted now and the reader updated when it answers." }
|
|
365
|
+
: {}),
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return { targets, excluded };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** What each account type is called in a sentence about a door. */
|
|
373
|
+
export function audienceLabel(audience: unknown): string {
|
|
374
|
+
const labels: Record<string, string> = {
|
|
375
|
+
resident: "residents",
|
|
376
|
+
residentVisitor: "resident visitors",
|
|
377
|
+
member: "members",
|
|
378
|
+
serviceProvider: "service providers",
|
|
379
|
+
};
|
|
380
|
+
return labels[String(audience ?? "")] ?? "that kind of person";
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/* ── HOW A FAN-OUT WENT ────────────────────────────────────────────────── */
|
|
384
|
+
|
|
385
|
+
export type THidReaderStep = "waiting" | "working" | "done" | "failed";
|
|
386
|
+
|
|
387
|
+
export type THidReaderOutcome = {
|
|
388
|
+
readerId: string;
|
|
389
|
+
name: string;
|
|
390
|
+
step: THidReaderStep;
|
|
391
|
+
/** What failed, in words the operator can act on. */
|
|
392
|
+
message?: string;
|
|
393
|
+
/** Whether a stored face is available, so Retry can be offered without the person. */
|
|
394
|
+
retryable?: boolean;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
/** "2 of 4 readers" — what the progress line says while a save is running. */
|
|
398
|
+
export function fanOutProgressText(outcomes: ReadonlyArray<THidReaderOutcome>): string {
|
|
399
|
+
const total = outcomes.length;
|
|
400
|
+
if (!total) return "";
|
|
401
|
+
const settled = outcomes.filter((o) => o.step === "done" || o.step === "failed").length;
|
|
402
|
+
return `${settled} of ${total} ${total === 1 ? "reader" : "readers"}`;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* What to say once a fan-out has finished.
|
|
407
|
+
*
|
|
408
|
+
* A PARTIAL RESULT IS NOT A FAILURE, and must not read as one: the person has
|
|
409
|
+
* access on the readers that worked, and the rest can be retried without them
|
|
410
|
+
* being present. Saying "enrolment failed" would send the operator to re-do work
|
|
411
|
+
* that is already done.
|
|
412
|
+
*/
|
|
413
|
+
export function fanOutSummary(outcomes: ReadonlyArray<THidReaderOutcome>): {
|
|
414
|
+
tone: "success" | "partial" | "error";
|
|
415
|
+
message: string;
|
|
416
|
+
} {
|
|
417
|
+
const total = outcomes.length;
|
|
418
|
+
const done = outcomes.filter((o) => o.step === "done").length;
|
|
419
|
+
const failed = outcomes.filter((o) => o.step === "failed").length;
|
|
420
|
+
|
|
421
|
+
if (!total) return { tone: "error", message: "No reader at this site serves this person." };
|
|
422
|
+
if (!failed) {
|
|
423
|
+
return {
|
|
424
|
+
tone: "success",
|
|
425
|
+
message: `Enrolled on ${done} ${done === 1 ? "reader" : "readers"}.`,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
if (!done) {
|
|
429
|
+
return {
|
|
430
|
+
tone: "error",
|
|
431
|
+
message: `Could not enroll on any of the ${total} ${total === 1 ? "reader" : "readers"}. Nothing was changed on them.`,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
return {
|
|
435
|
+
tone: "partial",
|
|
436
|
+
message: `Enrolled on ${done} of ${total} readers. ${failed} can be retried — the person does not need to be present.`,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* What to say after an EDIT has been applied across readers.
|
|
442
|
+
*
|
|
443
|
+
* Different from an enrolment's summary in one way that matters: a PIN.
|
|
444
|
+
*
|
|
445
|
+
* A face that reached three readers of four is incomplete - the person opens
|
|
446
|
+
* three doors. A PIN that reached three of four is WORSE than incomplete: the
|
|
447
|
+
* old PIN still works on the fourth, so they hold two PINs and have no way to
|
|
448
|
+
* know which door wants which. That is the one partial state worth naming
|
|
449
|
+
* explicitly rather than folding into "2 of 3".
|
|
450
|
+
*/
|
|
451
|
+
export function editFanOutSummary(
|
|
452
|
+
outcomes: ReadonlyArray<THidReaderOutcome>,
|
|
453
|
+
pinChanged: boolean,
|
|
454
|
+
): { tone: "success" | "partial" | "error"; message: string } {
|
|
455
|
+
const total = outcomes.length;
|
|
456
|
+
const done = outcomes.filter((o) => o.step === "done").length;
|
|
457
|
+
const failed = outcomes.filter((o) => o.step === "failed").length;
|
|
458
|
+
|
|
459
|
+
if (!total) return { tone: "error", message: "This person is not on any reader." };
|
|
460
|
+
if (!failed) {
|
|
461
|
+
return {
|
|
462
|
+
tone: "success",
|
|
463
|
+
message: total === 1
|
|
464
|
+
? "Updated on this reader."
|
|
465
|
+
: `Updated on all ${total} readers.`,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
if (!done) {
|
|
469
|
+
return {
|
|
470
|
+
tone: "error",
|
|
471
|
+
message: `Could not update any of the ${total} ${total === 1 ? "reader" : "readers"}.`,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const stale = outcomes.filter((o) => o.step === "failed").map((o) => o.name).join(", ");
|
|
476
|
+
return {
|
|
477
|
+
tone: "partial",
|
|
478
|
+
message: pinChanged
|
|
479
|
+
? `Updated on ${done} of ${total} readers. ${stale} still ${failed === 1 ? "has" : "have"} the OLD PIN —`
|
|
480
|
+
+ " this person now has two different PINs. Save again to finish."
|
|
481
|
+
: `Updated on ${done} of ${total} readers. ${stale} ${failed === 1 ? "was" : "were"} not reached. Save again to finish.`,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/* ── DELETING SOMEBODY WHO IS ON SEVERAL READERS ───────────────────────── */
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* A delete used to be read as "remove this row", which was true when a person
|
|
489
|
+
* lived on one reader. Once enrolment fans out, the same person is on several,
|
|
490
|
+
* and "delete" is ambiguous in the one direction that matters: removing them
|
|
491
|
+
* everywhere when the operator meant one door is an access change they did not
|
|
492
|
+
* ask for and cannot see from this screen.
|
|
493
|
+
*
|
|
494
|
+
* So the dialog asks, and the default is the narrow one - this reader. Widening
|
|
495
|
+
* is a deliberate click.
|
|
496
|
+
*/
|
|
497
|
+
export function deleteScopeChoices(
|
|
498
|
+
readerCount: number,
|
|
499
|
+
hereName: string,
|
|
500
|
+
): Array<{ label: string; value: "here" | "all" }> {
|
|
501
|
+
const here = { label: `This reader only (${hereName || "this reader"})`, value: "here" as const };
|
|
502
|
+
if (readerCount < 2) return [here];
|
|
503
|
+
return [here, { label: `All ${readerCount} readers`, value: "all" as const }];
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** "This person is also on Gym and Car Park." - said before, not after. */
|
|
507
|
+
export function deleteReachText(
|
|
508
|
+
places: ReadonlyArray<{ readerId: string; name: string }>,
|
|
509
|
+
hereReaderId: string,
|
|
510
|
+
): string {
|
|
511
|
+
const others = places
|
|
512
|
+
.filter((p) => p.readerId !== hereReaderId)
|
|
513
|
+
.map((p) => p.name || "a reader");
|
|
514
|
+
if (!others.length) return "This person is only on this reader.";
|
|
515
|
+
const names = others.length === 1
|
|
516
|
+
? others[0]
|
|
517
|
+
: `${others.slice(0, -1).join(", ")} and ${others[others.length - 1]}`;
|
|
518
|
+
return `This person is also on ${names}.`;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* What to say after a delete has run across readers.
|
|
523
|
+
*
|
|
524
|
+
* The failure here is the mirror of the edit's: a delete that reached three
|
|
525
|
+
* readers of four leaves the person still able to open the fourth door. Naming
|
|
526
|
+
* that reader is the whole message - "2 of 3" alone tells an operator a door is
|
|
527
|
+
* still open without telling them which.
|
|
528
|
+
*/
|
|
529
|
+
export function deleteFanOutSummary(
|
|
530
|
+
outcomes: ReadonlyArray<THidReaderOutcome>,
|
|
531
|
+
): { tone: "success" | "partial" | "error"; message: string } {
|
|
532
|
+
const total = outcomes.length;
|
|
533
|
+
const done = outcomes.filter((o) => o.step === "done").length;
|
|
534
|
+
const failed = outcomes.filter((o) => o.step === "failed");
|
|
535
|
+
|
|
536
|
+
if (!total) return { tone: "error", message: "There was nothing to delete." };
|
|
537
|
+
if (!failed.length) {
|
|
538
|
+
return {
|
|
539
|
+
tone: "success",
|
|
540
|
+
message: total === 1
|
|
541
|
+
? "Deleted from this reader."
|
|
542
|
+
: `Deleted from all ${total} readers.`,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
if (!done) {
|
|
546
|
+
return {
|
|
547
|
+
tone: "error",
|
|
548
|
+
message: `Could not delete from any of the ${total} ${total === 1 ? "reader" : "readers"}.`,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
const left = failed.map((o) => o.name).join(", ");
|
|
552
|
+
return {
|
|
553
|
+
tone: "partial",
|
|
554
|
+
message: `Deleted from ${done} of ${total} readers. This person can STILL open`
|
|
555
|
+
+ ` ${left}. Delete again to finish.`,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE NEXT REGISTRATION NUMBER ON ONE READER.
|
|
3
|
+
*
|
|
4
|
+
* Each reader numbers its own users, so the input is that reader's existing
|
|
5
|
+
* registration numbers (from the device, plus the roster on screen when it is
|
|
6
|
+
* the selected reader). Numeric ones count; the next is the highest plus one,
|
|
7
|
+
* zero padded to six digits or to the widest number already in use, whichever
|
|
8
|
+
* is wider. Anything that is not a plain number (`USR-...`, blank, undefined)
|
|
9
|
+
* is ignored rather than breaking the allocation.
|
|
10
|
+
*
|
|
11
|
+
* Pure. `HidUserEnrollment.vue` reads the lists and calls this.
|
|
12
|
+
*/
|
|
13
|
+
export function nextRegistration(registrations: readonly unknown[]): string {
|
|
14
|
+
let max = 0;
|
|
15
|
+
let width = 6;
|
|
16
|
+
for (const value of registrations) {
|
|
17
|
+
const text = String(value ?? "").trim();
|
|
18
|
+
const match = text.match(/^(\d+)$/);
|
|
19
|
+
if (!match) continue;
|
|
20
|
+
max = Math.max(max, Number(match[1]));
|
|
21
|
+
width = Math.max(width, match[1].length);
|
|
22
|
+
}
|
|
23
|
+
return String(max + 1).padStart(width, "0");
|
|
24
|
+
}
|
package/utils/module-routes.ts
CHANGED
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
* Only prefixes whose page each app gates on exactly one module are listed
|
|
12
12
|
* (checked against every app's own `useLocalPermission` nav gate, 2026-09-11).
|
|
13
13
|
* Shared or ambiguous pages stay out: calendar, keys (gated on `site-settings`),
|
|
14
|
-
* access-mgmt (
|
|
15
|
-
* inventory, service-providers and
|
|
14
|
+
* the Cards and QR Tagging pages under access-mgmt (each app's own
|
|
15
|
+
* `access-card-mgmt` gate), equipment and inventory, service-providers and
|
|
16
|
+
* support tickets. The HID pages under access-mgmt ARE listed, one by one,
|
|
17
|
+
* because each is gated on `hid-mgmt` alone (`useHidAccessPermission`).
|
|
16
18
|
*
|
|
17
19
|
* NEVER MAPPED: the dashboard, settings, members, invitations and roles. A client
|
|
18
20
|
* must always be able to run its own people and roles; the server never denies
|
|
@@ -68,6 +70,17 @@ export const MODULE_ROUTES: Readonly<Record<string, string>> = Object.freeze({
|
|
|
68
70
|
"org-site-pool-schedule-area": "area-mgmt",
|
|
69
71
|
"org-site-pool-schedule-unit": "unit-mgmt",
|
|
70
72
|
"org-site-service-provider-mgmt": "service-provider-mgmt",
|
|
73
|
+
// HID access management (decision of 2026-10-16): the seven HID pages under
|
|
74
|
+
// `access-mgmt`, named one by one so the Cards and QR Tagging pages beside
|
|
75
|
+
// them stay unmapped. `hid-mgmt` is a governed module in core, so a client
|
|
76
|
+
// that was not given it loses the pages and the drawer group together.
|
|
77
|
+
"org-site-access-mgmt-hid-readers": "hid-mgmt",
|
|
78
|
+
"org-site-access-mgmt-administrator": "hid-mgmt",
|
|
79
|
+
"org-site-access-mgmt-hid-users": "hid-mgmt",
|
|
80
|
+
"org-site-access-mgmt-hid-qr-code": "hid-mgmt",
|
|
81
|
+
"org-site-access-mgmt-hid-cards": "hid-mgmt",
|
|
82
|
+
"org-site-access-mgmt-intercom": "hid-mgmt",
|
|
83
|
+
"org-site-access-mgmt-access-logs": "hid-mgmt",
|
|
71
84
|
"org-site-online-forms": "online-form-configuration",
|
|
72
85
|
"org-site-nfc": "nfc-patrol",
|
|
73
86
|
"org-site-document-mgmt": "document-mgmt",
|
package/utils/nav-sections.ts
CHANGED
|
@@ -77,7 +77,7 @@ export const NAV_SECTIONS: Record<string, string> = {
|
|
|
77
77
|
"SOA Mgmt": "PROPERTY",
|
|
78
78
|
|
|
79
79
|
// SECURITY OPERATIONS
|
|
80
|
-
"HID
|
|
80
|
+
"HID Reader": "SECURITY OPERATIONS",
|
|
81
81
|
"Daily Occurrence Books": "SECURITY OPERATIONS",
|
|
82
82
|
Manpower: "SECURITY OPERATIONS",
|
|
83
83
|
"Virtual Patrol": "SECURITY OPERATIONS",
|