@7365admin1/layer-common 4.97.1-staging.508 → 4.97.1-staging.510
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.
|
@@ -77,6 +77,23 @@
|
|
|
77
77
|
<p class="reader-audience__hint">{{ field.hint }}</p>
|
|
78
78
|
</div>
|
|
79
79
|
</div>
|
|
80
|
+
<!-- WHAT THIS SAVE TAKES AWAY.
|
|
81
|
+
Switching an account type off revokes the people already granted
|
|
82
|
+
this reader - their credentials stay on the device, so they go from
|
|
83
|
+
authorised to recognised-and-refused and back again if it is switched
|
|
84
|
+
on later. Said with a number, because "removes access" and "removes
|
|
85
|
+
access for 40 people" are different decisions. -->
|
|
86
|
+
<p v-if="revocationWarning" class="reader-discovery-hint reader-audiences__revoking mb-1">
|
|
87
|
+
{{ revocationWarning }}
|
|
88
|
+
</p>
|
|
89
|
+
<!-- And whether anybody is left with a door at all. -->
|
|
90
|
+
<p v-if="strandedWarning" class="reader-discovery-hint reader-audiences__empty mb-1">
|
|
91
|
+
{{ strandedWarning }}
|
|
92
|
+
</p>
|
|
93
|
+
<p v-if="needsConfirmation && revocationConfirmed" class="reader-discovery-hint reader-audiences__revoking mb-3">
|
|
94
|
+
Press Save again to apply this.
|
|
95
|
+
</p>
|
|
96
|
+
|
|
80
97
|
<p v-if="noAudienceSelected" class="reader-discovery-hint reader-audiences__empty mb-3">
|
|
81
98
|
Nobody can be enrolled on this reader while all four are off. Existing
|
|
82
99
|
people keep the access they already have.
|
|
@@ -243,7 +260,7 @@
|
|
|
243
260
|
:disabled="mode === 'add' && (!draft.deviceId || !draft.portalId || draft.cardReadingMode === 'unconfigured')"
|
|
244
261
|
@click="submit"
|
|
245
262
|
>
|
|
246
|
-
|
|
263
|
+
{{ submitLabel }}
|
|
247
264
|
</v-btn>
|
|
248
265
|
</v-card-actions>
|
|
249
266
|
</v-card>
|
|
@@ -255,8 +272,11 @@
|
|
|
255
272
|
kept in a util with tests because the absent-means-all default is the part
|
|
256
273
|
that could close a door by accident. */
|
|
257
274
|
import {
|
|
275
|
+
audienceRevocations,
|
|
276
|
+
audiencesLeftWithNoReader,
|
|
258
277
|
HID_READER_AUDIENCE_FIELDS,
|
|
259
278
|
readerAudiences,
|
|
279
|
+
type THidPermissionCounts,
|
|
260
280
|
type THidReaderAudiences,
|
|
261
281
|
} from "../utils/hid-reader-audiences";
|
|
262
282
|
|
|
@@ -325,6 +345,23 @@ const props = defineProps({
|
|
|
325
345
|
type: String,
|
|
326
346
|
default: "",
|
|
327
347
|
},
|
|
348
|
+
// Which organisation's permission document to read the granted counts from.
|
|
349
|
+
// The reader's audiences are the reader's, but who is granted under them is
|
|
350
|
+
// per organisation - see `getSitePermissions`.
|
|
351
|
+
org: {
|
|
352
|
+
type: String,
|
|
353
|
+
default: "",
|
|
354
|
+
},
|
|
355
|
+
/*
|
|
356
|
+
* Every reader at this site, so switching the LAST one serving an account type
|
|
357
|
+
* can be warned about. Passed in rather than fetched: the parent already holds
|
|
358
|
+
* the list it draws the table from, and a second request would be a second
|
|
359
|
+
* answer that could disagree with what is on screen.
|
|
360
|
+
*/
|
|
361
|
+
siteReaders: {
|
|
362
|
+
type: Array as () => Array<Record<string, unknown>>,
|
|
363
|
+
default: () => [],
|
|
364
|
+
},
|
|
328
365
|
});
|
|
329
366
|
|
|
330
367
|
const emit = defineEmits<{
|
|
@@ -373,6 +410,94 @@ const audiences = reactive<THidReaderAudiences>({
|
|
|
373
410
|
serviceProvider: true,
|
|
374
411
|
});
|
|
375
412
|
const audienceFields = HID_READER_AUDIENCE_FIELDS;
|
|
413
|
+
|
|
414
|
+
/*
|
|
415
|
+
* WHAT THE READER LOOKED LIKE WHEN THE DIALOG OPENED.
|
|
416
|
+
*
|
|
417
|
+
* Only an on-to-off change costs anybody their access, so the warnings compare
|
|
418
|
+
* against this rather than against the stored record - which the switches are
|
|
419
|
+
* already editing.
|
|
420
|
+
*/
|
|
421
|
+
const audiencesOnOpen = ref<THidReaderAudiences>({ ...audiences });
|
|
422
|
+
|
|
423
|
+
/** Who is granted on this reader, per category. Read once per opening. */
|
|
424
|
+
const grantedCounts = ref<THidPermissionCounts | null>(null);
|
|
425
|
+
const { getSitePermissions } = useHidAmico();
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* NEVER THROWS, and a failure means the counts are simply unknown.
|
|
429
|
+
*
|
|
430
|
+
* The warning below says how many people a switch-off costs; without the counts
|
|
431
|
+
* it says that it will remove access without a number, which is still the thing
|
|
432
|
+
* the operator needs to know. Failing the dialog over a count would be worse.
|
|
433
|
+
*/
|
|
434
|
+
async function loadGrantedCounts() {
|
|
435
|
+
grantedCounts.value = null;
|
|
436
|
+
const readerId = String(props.reader?._id ?? "");
|
|
437
|
+
if (!readerId || !props.site || !props.org) return;
|
|
438
|
+
try {
|
|
439
|
+
const response = await getSitePermissions(props.site, props.org, readerId);
|
|
440
|
+
grantedCounts.value = (response?.data?.counts ?? null) as THidPermissionCounts | null;
|
|
441
|
+
} catch (error: unknown) {
|
|
442
|
+
console.error("Unable to read who is granted on this reader:", error);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** The account types this edit switches off, and what each costs. */
|
|
447
|
+
const revocations = computed(() => audienceRevocations(
|
|
448
|
+
audiencesOnOpen.value,
|
|
449
|
+
audiences,
|
|
450
|
+
grantedCounts.value,
|
|
451
|
+
));
|
|
452
|
+
|
|
453
|
+
/** Account types this edit would leave with no reader anywhere at the site. */
|
|
454
|
+
const strandedAudiences = computed(() => audiencesLeftWithNoReader(
|
|
455
|
+
props.reader?._id,
|
|
456
|
+
audiences,
|
|
457
|
+
props.siteReaders,
|
|
458
|
+
));
|
|
459
|
+
|
|
460
|
+
const revocationWarning = computed(() => {
|
|
461
|
+
if (!revocations.value.length) return "";
|
|
462
|
+
return revocations.value
|
|
463
|
+
.map((item) => {
|
|
464
|
+
if (item.audience === "residentVisitor") {
|
|
465
|
+
// Not a revocation: a visitor pass is bound through `user_access_rules`
|
|
466
|
+
// with its own expiry, so issued passes run out rather than stopping.
|
|
467
|
+
return `${item.label}: no new passes will be issued. Passes already issued run out on their own.`;
|
|
468
|
+
}
|
|
469
|
+
if (!item.people) {
|
|
470
|
+
return `${item.label}: nobody is currently granted this reader under it.`;
|
|
471
|
+
}
|
|
472
|
+
return `${item.label}: removes access for ${item.people} ${item.people === 1 ? "person" : "people"}.`;
|
|
473
|
+
})
|
|
474
|
+
.join(" ");
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
const strandedWarning = computed(() => {
|
|
478
|
+
if (!strandedAudiences.value.length) return "";
|
|
479
|
+
const labels = strandedAudiences.value.map((item) => item.label);
|
|
480
|
+
const subject = labels.length === 1 ? labels[0] : labels.join(", ");
|
|
481
|
+
return `No other reader at this site serves ${subject}.`
|
|
482
|
+
+ ` Saving this leaves ${labels.length === 1 ? "that account type" : "those account types"} with no way in.`;
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
/*
|
|
486
|
+
* A SECOND PRESS when something is being taken away.
|
|
487
|
+
*
|
|
488
|
+
* Revoking forty people is not an edit to make on one click, and a dialog over
|
|
489
|
+
* a dialog for a switch is heavier than it needs to be. Save states what it is
|
|
490
|
+
* about to do and then does it.
|
|
491
|
+
*/
|
|
492
|
+
const revocationConfirmed = ref(false);
|
|
493
|
+
const needsConfirmation = computed(
|
|
494
|
+
() => Boolean(revocations.value.length || strandedAudiences.value.length)
|
|
495
|
+
);
|
|
496
|
+
const submitLabel = computed(() => (
|
|
497
|
+
needsConfirmation.value && !revocationConfirmed.value
|
|
498
|
+
? "Review and save"
|
|
499
|
+
: props.mode === "edit" ? "Save Changes" : "Add Reader"
|
|
500
|
+
));
|
|
376
501
|
/* Switching every audience off leaves a reader nobody can be enrolled on, which
|
|
377
502
|
is legal but almost never meant - so it is said out loud rather than refused. */
|
|
378
503
|
const noAudienceSelected = computed(() =>
|
|
@@ -421,6 +546,10 @@ watch(
|
|
|
421
546
|
// existed opens with all four on rather than all four off - and a save from
|
|
422
547
|
// this form therefore cannot close it by accident.
|
|
423
548
|
Object.assign(audiences, readerAudiences(props.reader as Record<string, unknown> | null));
|
|
549
|
+
// The baseline the warnings compare against, and the counts behind them.
|
|
550
|
+
audiencesOnOpen.value = { ...audiences };
|
|
551
|
+
revocationConfirmed.value = false;
|
|
552
|
+
void loadGrantedCounts();
|
|
424
553
|
draft.username = props.reader?.username ?? "";
|
|
425
554
|
draft.password = props.reader?.password ?? "";
|
|
426
555
|
monitorEnabled.value = props.reader?.monitorStatus !== "disabled";
|
|
@@ -515,6 +644,14 @@ function submit() {
|
|
|
515
644
|
discoveryError.value = "Select the model printed on the physical HID reader.";
|
|
516
645
|
return;
|
|
517
646
|
}
|
|
647
|
+
/*
|
|
648
|
+
* The first press on a save that takes access away only acknowledges it. The
|
|
649
|
+
* warnings are already on screen; this makes the operator read them.
|
|
650
|
+
*/
|
|
651
|
+
if (needsConfirmation.value && !revocationConfirmed.value) {
|
|
652
|
+
revocationConfirmed.value = true;
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
518
655
|
const portal = portals.value.find((item) => item.id === Number(draft.portalId));
|
|
519
656
|
const payload: HidReaderFormPayload = {
|
|
520
657
|
...draft,
|
|
@@ -556,6 +693,13 @@ function submit() {
|
|
|
556
693
|
color: var(--err);
|
|
557
694
|
}
|
|
558
695
|
|
|
696
|
+
/* A revocation is a warning rather than an error: it is a legitimate thing to
|
|
697
|
+
do, deliberately, and the colour should not imply the save is refused. */
|
|
698
|
+
.reader-audiences__revoking {
|
|
699
|
+
color: var(--warn, var(--err));
|
|
700
|
+
font-weight: 600;
|
|
701
|
+
}
|
|
702
|
+
|
|
559
703
|
/* Type comes from `.screen-modal .v-card-title`; only the header strip's own
|
|
560
704
|
padding and rule stay here. `#ececec` was a fixed near-white line, i.e.
|
|
561
705
|
invisible on a light card and a bright scratch across a dark one. */
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
<p class="spc-intro">
|
|
19
19
|
<template v-if="kind === 'owner'">
|
|
20
20
|
{{ siteName || "This site" }} moves to a new owner, who takes charge of all of its records.
|
|
21
|
-
Every service provider at the site keeps working as it is
|
|
22
|
-
|
|
21
|
+
Every service provider at the site keeps working as it is, and the new owner needs no invitation. Only Seven365
|
|
22
|
+
can do this.
|
|
23
23
|
</template>
|
|
24
24
|
<template v-else>
|
|
25
25
|
Only {{ preview?.service || "this service" }} at {{ siteName || "this site" }} changes. Other services at the site,
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
</p>
|
|
29
29
|
|
|
30
30
|
<label class="spc-label" for="spc-to">{{ kind === "owner" ? "New owner" : "New company (optional)" }}</label>
|
|
31
|
-
<v-
|
|
31
|
+
<v-autocomplete
|
|
32
32
|
id="spc-to"
|
|
33
33
|
v-model="to"
|
|
34
34
|
:items="toItems"
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
variant="outlined"
|
|
39
39
|
class="filter-field"
|
|
40
40
|
:disabled="saving"
|
|
41
|
-
:no-data-text="kind === 'owner' ? '
|
|
41
|
+
:no-data-text="kind === 'owner' ? 'No company found.' : 'Invite the new company to the site first.'"
|
|
42
42
|
data-test="spc-to"
|
|
43
43
|
@update:model-value="refresh"
|
|
44
44
|
/>
|
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
Provider changes at {{ siteName || "this site" }}
|
|
97
97
|
<v-spacer />
|
|
98
98
|
<v-btn
|
|
99
|
-
v-if="ownerOrgId"
|
|
99
|
+
v-if="ownerOrgId && isStaff"
|
|
100
100
|
variant="outlined"
|
|
101
101
|
class="screen-btn-ghost mr-2"
|
|
102
102
|
data-test="spc-change-owner"
|
|
@@ -186,11 +186,24 @@ const snackText = ref("");
|
|
|
186
186
|
const snackColor = ref("success");
|
|
187
187
|
|
|
188
188
|
const tomorrow = computed(() => new Date(Date.now() + 864e5).toISOString().slice(0, 10));
|
|
189
|
-
const replacements = ref<Array<{ org: string; name: string }>>([]);
|
|
189
|
+
const replacements = ref<Array<{ org: string; name: string; nature?: string }>>([]);
|
|
190
|
+
const OWNER_KIND: Record<string, string> = {
|
|
191
|
+
property_management_agency: "Property management",
|
|
192
|
+
real_estate_developer: "Developer",
|
|
193
|
+
property_owner: "Owner",
|
|
194
|
+
};
|
|
190
195
|
const toItems = computed(() => [
|
|
191
196
|
...(kind.value === "provider" ? [{ title: "No new company yet - just end", value: null }] : []),
|
|
192
|
-
...replacements.value.map((r) => ({
|
|
197
|
+
...replacements.value.map((r) => ({
|
|
198
|
+
title: kind.value === "owner" && OWNER_KIND[r.nature ?? ""] ? `${r.name} - ${OWNER_KIND[r.nature ?? ""]}` : r.name,
|
|
199
|
+
value: r.org,
|
|
200
|
+
})),
|
|
193
201
|
]);
|
|
202
|
+
|
|
203
|
+
// Changing the site's owner is Seven365's job only (owner, 2026-10-06): a
|
|
204
|
+
// property manager must never see which other property managers use the system.
|
|
205
|
+
const { isStaff, load: loadTier } = useConsoleTier();
|
|
206
|
+
onMounted(() => loadTier());
|
|
194
207
|
const canConfirm = computed(
|
|
195
208
|
() =>
|
|
196
209
|
!loading.value &&
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "4.97.1-staging.
|
|
5
|
+
"version": "4.97.1-staging.510",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
|
|
@@ -177,3 +177,99 @@ export function availableEnrolmentSubjects<T extends { value: unknown }>(
|
|
|
177
177
|
emptied: !served.length,
|
|
178
178
|
};
|
|
179
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
|
+
}
|