@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
- Submit
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. */
@@ -111,8 +111,10 @@
111
111
  <HidReaderForm
112
112
  v-model="formDialog"
113
113
  :site="props.site"
114
+ :org="props.org"
114
115
  :mode="selectedReader ? 'edit' : 'add'"
115
116
  :reader="selectedReader"
117
+ :site-readers="readers"
116
118
  :loading="saving"
117
119
  @submit="saveReader"
118
120
  />
@@ -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. Only the new owner must already be working at the site
22
- (invite them first; Seven365 can pick any company).
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-select
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' ? 'Invite the new owner to the site first.' : 'Invite the new company to the site first.'"
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) => ({ title: r.name, value: r.org })),
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.508",
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
+ }