@7365admin1/layer-common 4.45.0 → 4.47.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.47.0
4
+
5
+ ### Minor Changes
6
+
7
+ - cde8389: `getVerifications` takes `origin`; the console Client List's Pending tab lists only invitations Seven365 sent.
8
+
9
+ ## 4.46.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 4cf067f: Robot building map editor: floor plans per floor (upload / replace / remove, stored privately in the map; Set scale by measuring a known length; Fit), HERO Fleet Core import into the saved map, and downloads of the building file and route maps. The 3D building map draws saved floor plans. Edit map is also offered at HERO Fleet Core sites (it edits the site's saved copy). The Add robot note says "stored encrypted" only when the server reports it.
14
+
3
15
  ## 4.45.0
4
16
 
5
17
  ### Minor Changes
@@ -1509,11 +1521,11 @@
1509
1521
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1510
1522
  own rule from two endpoints the console already calls, unprojected:
1511
1523
 
1512
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1513
- GET /api/roles/id/:role that membership's role document
1524
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1525
+ GET /api/roles/id/:role that membership's role document
1514
1526
 
1515
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1516
- staff = member.type === "admin" && role.type === "admin"
1527
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1528
+ staff = member.type === "admin" && role.type === "admin"
1517
1529
 
1518
1530
  `role.default` is the marker because it is the only property of a platform
1519
1531
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -1043,6 +1043,12 @@
1043
1043
  }
1044
1044
  .hero-editor__plan:focus-visible { outline: 2px solid var(--hero-accent); outline-offset: 0; }
1045
1045
  .hero-editor__room { position: absolute; border: 2px solid var(--hero-text); opacity: 0.4; pointer-events: none; }
1046
+ /* Floor plan image (stored privately in the map) under everything drawn; scale by measurement. */
1047
+ .hero-editor__planimg, .hero-bmap__planimg { position: absolute; max-width: none; pointer-events: none; user-select: none; opacity: 0.55; object-fit: fill; }
1048
+ .hero-editor__measure { stroke: var(--hero-accent); stroke-width: 2; stroke-dasharray: 4 3; }
1049
+ .hero-editor__mpoint { position: absolute; width: 10px; height: 10px; margin: -5px 0 0 -5px; border-radius: 50%; background: var(--hero-accent); pointer-events: none; }
1050
+ .hero-editor__measurebox { display: flex; flex-direction: column; gap: 6px; padding: 8px; border: 1px dashed var(--hero-accent); font-size: 12px; }
1051
+ .hero-editor__measurein { width: 110px; min-height: 32px; }
1046
1052
  .hero-editor__svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
1047
1053
  .hero-editor__wall { stroke: var(--hero-text); stroke-width: 1.5; }
1048
1054
  .hero-editor__zone-shape { fill: color-mix(in srgb, var(--hero-text) 10%, transparent); stroke: var(--hero-text); stroke-width: 1; stroke-dasharray: 4 3; }
@@ -636,6 +636,8 @@ async function fetchPending() {
636
636
  const res = await getVerifications({
637
637
  status: 'pending',
638
638
  type: 'user-invite',
639
+ // Only what Seven365 sent - never a client's own invitations (owner, 2026-09-25).
640
+ origin: 'seven365',
639
641
  page: page.value,
640
642
  search: search.value,
641
643
  email: search.value,
@@ -18,7 +18,7 @@
18
18
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" /></svg>
19
19
  Full screen
20
20
  </v-btn>
21
- <v-btn v-if="canEditMap && !fleet" :variant="editing ? 'outlined' : 'flat'" :color="editing ? undefined : 'primary'" @click="editing = !editing">
21
+ <v-btn v-if="canEditMap" :variant="editing ? 'outlined' : 'flat'" :color="editing ? undefined : 'primary'" @click="editing = !editing">
22
22
  {{ editing ? "Exit edit mode" : "Edit map" }}
23
23
  </v-btn>
24
24
  </div>
@@ -26,12 +26,15 @@
26
26
  <div v-if="error" class="hero-notice" role="alert">{{ error }}</div>
27
27
  <div v-else-if="!loading && emptyNote" class="hero-notice">{{ emptyNote }}</div>
28
28
 
29
- <div v-if="fleet" class="hero-notice" role="status">Live building from HERO Fleet Core - doors and lifts update every few seconds. It is managed by the fleet, so it cannot be edited here.</div>
29
+ <div v-if="fleet && !editing" class="hero-notice" role="status">Live building from HERO Fleet Core - doors and lifts update every few seconds. The fleet manages it; Edit map changes this site's saved copy (floor plans, import, downloads).</div>
30
+ <div v-else-if="fleet && editing" class="hero-notice" role="status">Editing this site's saved building map. The live HERO Fleet Core view is unchanged until you import.</div>
30
31
 
31
32
  <RobotMapEditor
32
- v-if="editing && canEditMap && !fleet"
33
+ v-if="editing && canEditMap"
33
34
  ref="editor"
34
- :map="map"
35
+ :map="saved"
36
+ :site="site"
37
+ :fleet-site="fleet"
35
38
  :floors="floors"
36
39
  :site-name="siteLabel"
37
40
  :owner-robot-id="ownerRobotId"
@@ -107,7 +110,8 @@
107
110
  class="hero-bmap__floor"
108
111
  :style="{ transform: `translateZ(${i * FLOOR_GAP_PX}px)`, opacity: f.dim, '--hero-fill': look.fill }"
109
112
  >
110
- <template v-if="!fleet">
113
+ <img v-if="f.plan" class="hero-bmap__planimg" :src="f.plan.url" :style="f.plan.style" alt="" aria-hidden="true" draggable="false" />
114
+ <template v-if="!fleet && !f.plan">
111
115
  <span class="hero-bmap__room" style="left: 8%; top: 10%; width: 34%; height: 30%" aria-hidden="true" />
112
116
  <span class="hero-bmap__room" style="left: 58%; top: 10%; width: 34%; height: 44%" aria-hidden="true" />
113
117
  <span class="hero-bmap__room" style="left: 8%; top: 62%; width: 60%; height: 28%" aria-hidden="true" />
@@ -201,6 +205,8 @@ import { computed, onBeforeUnmount, ref, toRef, watch } from "vue";
201
205
  import RobotMapEditor from "./MapEditor.vue";
202
206
  import RobotReconnecting from "./Reconnecting.vue";
203
207
  import useRobotBuilding from "../../composables/useRobotBuilding";
208
+ import useFloorPlanImages from "../../composables/useFloorPlanImages";
209
+ import { floorPlanFor, planBox } from "../../utils/robot-floorplan";
204
210
  import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
205
211
  import { openDoorNames } from "../../utils/robot-fleet-core";
206
212
  import {
@@ -264,7 +270,7 @@ const emit = defineEmits<{
264
270
  status: [status: { connected: boolean; faults: number; siteName: string; clientName: string }];
265
271
  }>();
266
272
 
267
- const { rows, map, fleet, names, loading, error, reload } = useRobotBuilding(
273
+ const { rows, map, saved, fleet, names, loading, error, reload } = useRobotBuilding(
268
274
  toRef(props, "organization"),
269
275
  toRef(props, "site"),
270
276
  toRef(props, "type"),
@@ -307,9 +313,19 @@ const floorRoutes = (floorNo: number) => {
307
313
  }
308
314
  return [...seen.values()];
309
315
  };
316
+ /** Each floor's saved plan image (kept privately in the map), drawn under the shapes. */
317
+ const { urls: planUrls } = useFloorPlanImages(saved);
318
+ function floorImage(floorNo: number) {
319
+ const p = floorPlanFor(saved.value, floorNo);
320
+ const url = planUrls.value[floorNo];
321
+ if (!p || !url) return null;
322
+ const b = planBox(p);
323
+ return { url, style: { left: `${b.left}%`, top: `${b.top}%`, width: `${b.width}%`, height: `${b.height}%` } };
324
+ }
310
325
  const floors3d = computed(() =>
311
326
  floors.value.map((floorNo) => ({
312
327
  floorNo,
328
+ plan: floorImage(floorNo),
313
329
  dim: floorOpacity(cam.value.tilt, focus.value, floorNo),
314
330
  shapes: shapes.value.filter((s) => s.kind !== "lift" && s.floorNo === floorNo),
315
331
  routes: props.type === "security" ? floorRoutes(floorNo) : [],
@@ -388,11 +404,11 @@ function fullscreen(el?: HTMLElement | null) {
388
404
  else el?.requestFullscreen?.();
389
405
  }
390
406
  const editing = ref(false);
391
- watch([() => props.canEditMap, fleet], ([can, isFleet]) => {
392
- if (!can || isFleet) editing.value = false;
407
+ watch(() => props.canEditMap, (can) => {
408
+ if (!can) editing.value = false;
393
409
  });
394
410
  /** The robot a first save is attached to (maps are stored per robot): one of this site's. */
395
- const ownerRobotId = computed(() => String(map.value?.robotId ?? rows.value[0]?._id ?? ""));
411
+ const ownerRobotId = computed(() => String(saved.value?.robotId ?? rows.value[0]?._id ?? ""));
396
412
  function onSaved() {
397
413
  reload();
398
414
  }
@@ -72,7 +72,7 @@
72
72
  />
73
73
  </div>
74
74
  </div>
75
- <div class="hero-dialog__note">{{ CREDENTIALS_NOTE }} Capability: {{ note }}</div>
75
+ <div class="hero-dialog__note">{{ credentialsNote(credentialsEncrypted) }} Capability: {{ note }}</div>
76
76
  <div v-if="error" class="hero-notice hero-notice--error" role="alert">{{ error }}</div>
77
77
  <div class="hero-dialog__actions">
78
78
  <v-btn variant="outlined" :disabled="saving" @click="emit('cancel')">Cancel</v-btn>
@@ -88,11 +88,11 @@ import {
88
88
  capabilityNote,
89
89
  credentialPatch,
90
90
  credentialPlaceholder,
91
- CREDENTIALS_NOTE,
92
91
  robotErrorMessage,
93
92
  typeLabel,
94
93
  type TRobotType,
95
94
  } from "../../utils/robot-display";
95
+ import { credentialsNote } from "../../utils/robot-floorplan";
96
96
 
97
97
  const props = withDefaults(
98
98
  defineProps<{
@@ -137,6 +137,8 @@ const saving = ref(false);
137
137
  const error = ref("");
138
138
 
139
139
  const brands = ref<Array<Record<string, any>>>([]);
140
+ /** The server says it stores credentials encrypted; only then does the note say so. */
141
+ const credentialsEncrypted = ref(false);
140
142
  const loadingBrands = ref(false);
141
143
  const options = computed(() => {
142
144
  const list = brandOptions(brands.value, type.value);
@@ -172,7 +174,9 @@ function onClient() {
172
174
  onMounted(async () => {
173
175
  loadingBrands.value = true;
174
176
  try {
175
- brands.value = (await getRobotBrands())?.items ?? [];
177
+ const res = await getRobotBrands();
178
+ brands.value = res?.items ?? [];
179
+ credentialsEncrypted.value = res?.credentialsEncrypted === true;
176
180
  } catch {
177
181
  brands.value = [];
178
182
  } finally {
@@ -11,6 +11,10 @@
11
11
  selects, Delete removes, Esc clears, Ctrl/Cmd+Z undo, Ctrl/Cmd+Shift+Z or
12
12
  Ctrl+Y redo. Every drag is one undo step. Save checks in plain words
13
13
  first, then writes the building map through the API (versioned).
14
+ Floor plan (per floor): upload / replace / remove an image kept privately
15
+ in the map, Set scale by measuring a known length, Fit. Building file:
16
+ import the HERO Fleet Core building, download the building file or a
17
+ route map.
14
18
  -->
15
19
  <div ref="root" class="hero-editor" :class="{ 'hero-editor--fs': isFs }">
16
20
  <div class="hero-editor__bar">
@@ -59,9 +63,12 @@
59
63
  @keydown="onKey"
60
64
  @blur="keyboard = false"
61
65
  >
62
- <span class="hero-editor__room" style="left: 8%; top: 10%; width: 34%; height: 30%" aria-hidden="true" />
63
- <span class="hero-editor__room" style="left: 58%; top: 10%; width: 34%; height: 44%" aria-hidden="true" />
64
- <span class="hero-editor__room" style="left: 8%; top: 62%; width: 60%; height: 28%" aria-hidden="true" />
66
+ <img v-if="floorPlan && planUrl" class="hero-editor__planimg" :src="planUrl" :style="planImgStyle" alt="" aria-hidden="true" draggable="false" />
67
+ <template v-else-if="!floorPlan">
68
+ <span class="hero-editor__room" style="left: 8%; top: 10%; width: 34%; height: 30%" aria-hidden="true" />
69
+ <span class="hero-editor__room" style="left: 58%; top: 10%; width: 34%; height: 44%" aria-hidden="true" />
70
+ <span class="hero-editor__room" style="left: 8%; top: 62%; width: 60%; height: 28%" aria-hidden="true" />
71
+ </template>
65
72
  <svg class="hero-editor__svg" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
66
73
  <template v-for="s in visible" :key="s.id">
67
74
  <polygon v-if="s.kind === 'zone'" class="hero-editor__zone-shape" :class="{ 'hero-editor__sel': isSel(s.id) }" :points="pts(s.points)" vector-effect="non-scaling-stroke" />
@@ -72,7 +79,9 @@
72
79
  </template>
73
80
  </template>
74
81
  <line v-if="rotateLine" class="hero-editor__rotline" :x1="rotateLine.x1" :y1="rotateLine.y1" :x2="rotateLine.x2" :y2="rotateLine.y2" vector-effect="non-scaling-stroke" />
82
+ <line v-if="measure.a && measure.b" class="hero-editor__measure" :x1="measure.a.x" :y1="measure.a.y" :x2="measure.b.x" :y2="measure.b.y" vector-effect="non-scaling-stroke" />
75
83
  </svg>
84
+ <span v-for="(m, i) in [measure.a, measure.b].filter(Boolean)" :key="`m${i}`" class="hero-editor__mpoint" :style="{ left: `${m!.x}%`, top: `${m!.y}%` }" aria-hidden="true" />
76
85
  <template v-for="s in visible" :key="`l-${s.id}`">
77
86
  <div v-if="s.kind === 'zone'" class="hero-editor__label" :style="boxStyle(s)" :title="shapeTitle(s)">{{ s.name || "Unnamed zone" }}</div>
78
87
  <div v-else-if="s.kind === 'lift'" class="hero-editor__lift" :class="{ 'hero-editor__sel': isSel(s.id) }" :style="boxStyle(s)" :title="shapeTitle(s)">Lift</div>
@@ -102,6 +111,35 @@
102
111
  <span>{{ shapeTitle(s) }}</span><span class="hero-editor__pos">{{ shapeAnchor(s).x }}, {{ shapeAnchor(s).y }}</span>
103
112
  </button>
104
113
  <div v-if="!visible.length" class="hero-muted">Nothing drawn yet. Pick a tool and click the plan.</div>
114
+
115
+ <div class="hero-aside__title hero-editor__asidetitle">Floor plan · {{ floorName(floor) }}</div>
116
+ <div class="hero-editor__actions">
117
+ <button type="button" class="hero-editor__act" :disabled="planLocked" :title="planLockText" @click="pickPlan">{{ planBusy ? "Saving…" : floorPlan ? "Replace plan" : "Upload plan" }}</button>
118
+ <template v-if="floorPlan">
119
+ <button type="button" class="hero-editor__act" :disabled="planLocked" :title="planLockText" :aria-pressed="measuring" @click="startMeasure">Set scale</button>
120
+ <button type="button" class="hero-editor__act" :disabled="planLocked" :title="planLockText" @click="fitPlanNow">Fit</button>
121
+ <button type="button" class="hero-editor__act hero-editor__act--del" :disabled="planLocked" :title="planLockText" @click="removePlanNow">Remove plan</button>
122
+ </template>
123
+ </div>
124
+ <input ref="planInput" type="file" accept="image/png,image/jpeg,image/webp" hidden @change="onPlanFile" />
125
+ <div v-if="planLockText && !planBusy" class="hero-muted">{{ planLockText }}</div>
126
+ <div v-if="floorPlan" class="hero-muted">{{ scaleText(floorPlan) }}</div>
127
+ <div v-if="measuring" class="hero-editor__measurebox" role="group" aria-label="Set scale">
128
+ <span>{{ measure.b ? "Real length between the two points, in metres:" : measure.a ? "Click the other end." : "Click one end of a length you know on the plan (a wall, a doorway)." }}</span>
129
+ <div class="hero-editor__actions">
130
+ <input v-if="measure.b" v-model.number="measureM" type="number" min="0.01" step="0.01" class="hero-input hero-editor__measurein" aria-label="Real length in metres" placeholder="e.g. 12.5" @keydown.enter.prevent="applyMeasure" />
131
+ <button v-if="measure.b" type="button" class="hero-editor__act" @click="applyMeasure">Apply</button>
132
+ <button type="button" class="hero-editor__act" @click="cancelMeasure">Cancel</button>
133
+ </div>
134
+ </div>
135
+
136
+ <div class="hero-aside__title hero-editor__asidetitle">Building file · HERO Fleet Core</div>
137
+ <div v-if="map?.rmf" class="hero-muted">{{ rmfLine(map.rmf) }}</div>
138
+ <div class="hero-editor__actions">
139
+ <button v-if="fleetSite" type="button" class="hero-editor__act" :disabled="planBusy || saving || state.dirty" :title="state.dirty ? 'Save your drawing first.' : ''" @click="importFleet">Import from HERO Fleet Core</button>
140
+ <button type="button" class="hero-editor__act" :disabled="!map?._id" @click="download('building')">Download building file</button>
141
+ <button v-for="g in graphs" :key="`g${g}`" type="button" class="hero-editor__act" @click="download('nav', g)">Download route map{{ graphs.length > 1 ? ` ${g}` : "" }}</button>
142
+ </div>
105
143
  <div class="hero-editor__note">The plan image comes from the robot; everything here is drawn on top of it. Keyboard: Tab to the plan; arrow keys move the cursor (or nudge what is selected, Shift for 1 m); Enter places or selects; Delete removes; Esc clears the selection.</div>
106
144
  </aside>
107
145
  </div>
@@ -111,6 +149,23 @@
111
149
  <script setup lang="ts">
112
150
  import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
113
151
  import { floorName, MAP_TOOLS } from "../../utils/robot-display";
152
+ import useFloorPlanImages from "../../composables/useFloorPlanImages";
153
+ import {
154
+ compressSize,
155
+ exportFileName,
156
+ fitPlan,
157
+ fleetImportBody,
158
+ floorPlanFileProblem,
159
+ floorPlanFor,
160
+ FLOOR_PLAN_MAX_BYTES,
161
+ planBox,
162
+ replacePlacement,
163
+ rescaleByMeasure,
164
+ rmfGraphs,
165
+ rmfLine,
166
+ scaleText,
167
+ type TPlanPoint,
168
+ } from "../../utils/robot-floorplan";
114
169
  import {
115
170
  DEFAULT_GRID_M,
116
171
  doorSwing,
@@ -155,11 +210,15 @@ const props = withDefaults(
155
210
  ownerRobotId?: string;
156
211
  /** The floor to open on. */
157
212
  startFloor?: number | null;
213
+ /** The site, for the HERO Fleet Core import. */
214
+ site?: string;
215
+ /** The site has HERO Fleet Core robots: offer "Import from HERO Fleet Core". */
216
+ fleetSite?: boolean;
158
217
  }>(),
159
- { map: null, siteName: "", ownerRobotId: "", startFloor: null },
218
+ { map: null, siteName: "", ownerRobotId: "", startFloor: null, site: "", fleetSite: false },
160
219
  );
161
220
  const emit = defineEmits<{ saved: [] }>();
162
- const { createBuildingMap, updateBuildingMap } = useRobots();
221
+ const { createBuildingMap, updateBuildingMap, saveFloorPlan, removeFloorPlan, exportBuildingMap, getFleetCoreBuildingData } = useRobots();
163
222
 
164
223
  const state = ref<TEditorState>(emptyEditor(shapesFromMap(props.map)));
165
224
  const dispatch = (a: TEditorAction) => (state.value = editorReduce(state.value, a));
@@ -240,6 +299,8 @@ function onPlanDown(e: PointerEvent) {
240
299
  plan.value?.focus({ preventScroll: true });
241
300
  keyboard.value = false;
242
301
  const p = at(e);
302
+ // Set scale: the two clicks are the measured length, not drawing.
303
+ if (measuring.value) return void measureClick(p);
243
304
  const target = hitShape(visible.value, p, tolerance(e.pointerType));
244
305
  const t = tool.value;
245
306
  if (t === "delete") {
@@ -464,6 +525,182 @@ async function save() {
464
525
  }
465
526
  }
466
527
 
528
+ /* ---- floor plan: stored in the map (not the public file store), per floor ---- */
529
+ const { urls: planUrls } = useFloorPlanImages(computed(() => props.map));
530
+ const floorPlan = computed(() => floorPlanFor(props.map, floor.value));
531
+ // (`plan` is the plan ELEMENT, for pointer maths; this floor's image record is `floorPlan`.)
532
+ const planUrl = computed(() => planUrls.value[floor.value] ?? "");
533
+ const planImgStyle = computed(() => {
534
+ const b = planBox(floorPlan.value!);
535
+ return { left: `${b.left}%`, top: `${b.top}%`, width: `${b.width}%`, height: `${b.height}%` };
536
+ });
537
+ const planBusy = ref(false);
538
+ const planLockText = computed(() =>
539
+ !props.map?._id ? "Save the map once, then add a floor plan." : state.value.dirty ? "Save your drawing first, then change the floor plan." : "",
540
+ );
541
+ const planLocked = computed(() => planBusy.value || saving.value || Boolean(planLockText.value));
542
+ const planInput = ref<HTMLInputElement | null>(null);
543
+ const note = (text: string, ok = false) => dispatch({ type: "notice", text, ok });
544
+ const errText = (e: any, fallback: string) => e?.data?.message || e?.message || fallback;
545
+
546
+ function pickPlan() {
547
+ if (!planLocked.value) planInput.value?.click();
548
+ }
549
+
550
+ const blobToDataUrl = (b: Blob) =>
551
+ new Promise<string>((ok, no) => {
552
+ const r = new FileReader();
553
+ r.onload = () => ok(String(r.result));
554
+ r.onerror = () => no(r.error);
555
+ r.readAsDataURL(b);
556
+ });
557
+
558
+ /** Shrink in the browser until it fits the 1 MB the server keeps: WebP (JPEG where WebP cannot be written), then smaller. */
559
+ async function compressPlan(file: File) {
560
+ const bmp = await createImageBitmap(file);
561
+ let { width, height } = compressSize(bmp.width, bmp.height);
562
+ const canvas = document.createElement("canvas");
563
+ try {
564
+ for (let attempt = 0; attempt < 8; attempt++) {
565
+ canvas.width = width;
566
+ canvas.height = height;
567
+ const ctx = canvas.getContext("2d")!;
568
+ ctx.fillStyle = "#fff";
569
+ ctx.fillRect(0, 0, width, height);
570
+ ctx.drawImage(bmp, 0, 0, width, height);
571
+ for (const q of [0.85, 0.7, 0.55]) {
572
+ let out = await new Promise<Blob | null>((r) => canvas.toBlob(r, "image/webp", q));
573
+ if (out?.type !== "image/webp") out = await new Promise<Blob | null>((r) => canvas.toBlob(r, "image/jpeg", q));
574
+ if (out && out.size <= FLOOR_PLAN_MAX_BYTES) return { image: await blobToDataUrl(out), widthPx: width, heightPx: height };
575
+ }
576
+ width = Math.max(1, Math.round(width * 0.8));
577
+ height = Math.max(1, Math.round(height * 0.8));
578
+ }
579
+ } finally {
580
+ bmp.close?.();
581
+ }
582
+ throw new Error("This floor plan cannot be made small enough. Try a simpler or smaller image.");
583
+ }
584
+
585
+ async function onPlanFile(e: Event) {
586
+ const input = e.target as HTMLInputElement;
587
+ const file = input.files?.[0];
588
+ input.value = "";
589
+ if (!file || !props.map?._id) return;
590
+ const problem = floorPlanFileProblem(file);
591
+ if (problem) return void note(problem);
592
+ planBusy.value = true;
593
+ try {
594
+ const img = await compressPlan(file);
595
+ await saveFloorPlan(String(props.map._id), floor.value, {
596
+ version: Number(props.map.version) || 1,
597
+ ...img,
598
+ ...replacePlacement(floorPlan.value, img.widthPx, img.heightPx),
599
+ });
600
+ note(`Floor plan saved for ${floorName(floor.value)}. Use Set scale to measure a known length.`, true);
601
+ emit("saved");
602
+ } catch (err) {
603
+ note(errText(err, "Could not save the floor plan."));
604
+ } finally {
605
+ planBusy.value = false;
606
+ }
607
+ }
608
+
609
+ async function placePlan(next: { metersPerPixel: number; tx: number; ty: number }, done: string) {
610
+ const p = floorPlan.value;
611
+ if (!p || !props.map?._id) return;
612
+ planBusy.value = true;
613
+ try {
614
+ await saveFloorPlan(String(props.map._id), floor.value, { version: Number(props.map.version) || 1, widthPx: p.widthPx, heightPx: p.heightPx, ...next });
615
+ note(done, true);
616
+ emit("saved");
617
+ } catch (err) {
618
+ note(errText(err, "Could not save the floor plan scale."));
619
+ } finally {
620
+ planBusy.value = false;
621
+ }
622
+ }
623
+
624
+ const fitPlanNow = () => floorPlan.value && placePlan(fitPlan(floorPlan.value.widthPx, floorPlan.value.heightPx), "Floor plan fitted to the drawing area.");
625
+
626
+ async function removePlanNow() {
627
+ if (!props.map?._id || !floorPlan.value) return;
628
+ if (!window.confirm(`Remove the floor plan of ${floorName(floor.value)}? Walls, doors and zones stay.`)) return;
629
+ planBusy.value = true;
630
+ try {
631
+ await removeFloorPlan(String(props.map._id), floor.value, Number(props.map.version) || 1);
632
+ note(`Floor plan removed from ${floorName(floor.value)}.`, true);
633
+ emit("saved");
634
+ } catch (err) {
635
+ note(errText(err, "Could not remove the floor plan."));
636
+ } finally {
637
+ planBusy.value = false;
638
+ }
639
+ }
640
+
641
+ /* ---- Set scale: two clicks on a known length, then its real length ---- */
642
+ const measuring = ref(false);
643
+ const measure = ref<{ a: TPlanPoint | null; b: TPlanPoint | null }>({ a: null, b: null });
644
+ const measureM = ref<number | null>(null);
645
+ function startMeasure() {
646
+ measuring.value = !measuring.value;
647
+ measure.value = { a: null, b: null };
648
+ measureM.value = null;
649
+ }
650
+ function measureClick(p: TPlanPoint) {
651
+ measure.value = !measure.value.a || measure.value.b ? { a: p, b: null } : { ...measure.value, b: p };
652
+ }
653
+ function cancelMeasure() {
654
+ measuring.value = false;
655
+ measure.value = { a: null, b: null };
656
+ }
657
+ async function applyMeasure() {
658
+ const { a, b } = measure.value;
659
+ if (!a || !b || !floorPlan.value) return;
660
+ const r = rescaleByMeasure(floorPlan.value, a, b, Number(measureM.value));
661
+ if ("error" in r) return void note(r.error);
662
+ cancelMeasure();
663
+ await placePlan(r.plan, `Scale set: ${scaleText(r.plan)}.`);
664
+ }
665
+ watch(floor, cancelMeasure);
666
+
667
+ /* ---- building file: HERO Fleet Core import, downloads ---- */
668
+ const graphs = computed(() => rmfGraphs(props.map?.rmf));
669
+
670
+ async function importFleet() {
671
+ if (!props.site) return;
672
+ if (props.map?._id && !window.confirm("Replace this building's walls, doors, lifts and routes with the HERO Fleet Core building? Floor plans stay.")) return;
673
+ if (!props.map?._id && !props.ownerRobotId) return void note("Can't import yet: this site has no robot to keep the map with.");
674
+ planBusy.value = true;
675
+ try {
676
+ const res = await getFleetCoreBuildingData(props.site);
677
+ const body = fleetImportBody(props.map, res?.rmf ?? {}, props.siteName);
678
+ if (props.map?._id) await updateBuildingMap(String(props.map._id), { ...body, version: Number(props.map.version) || 1 });
679
+ else await createBuildingMap({ ...body, robotId: props.ownerRobotId });
680
+ note(`Imported from HERO Fleet Core: ${rmfLine(res?.rmf)}.`, true);
681
+ emit("saved");
682
+ } catch (err) {
683
+ note(errText(err, "Could not import the HERO Fleet Core building."));
684
+ } finally {
685
+ planBusy.value = false;
686
+ }
687
+ }
688
+
689
+ async function download(format: "building" | "nav", graph = 0) {
690
+ if (!props.map?._id) return;
691
+ try {
692
+ const blob = await exportBuildingMap(String(props.map._id), format, format === "nav" ? graph : undefined);
693
+ const url = URL.createObjectURL(blob);
694
+ const a = document.createElement("a");
695
+ a.href = url;
696
+ a.download = exportFileName(String(props.map.name ?? ""), format, graph);
697
+ a.click();
698
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
699
+ } catch (err) {
700
+ note(errText(err, "Could not download the file."));
701
+ }
702
+ }
703
+
467
704
  /* ---- drawing helpers + full screen ---- */
468
705
  const pts = (list: Array<{ x: number; y: number }>) => list.map((p) => `${p.x},${p.y}`).join(" ");
469
706
  function boxStyle(s: TEditorShape) {
@@ -0,0 +1,52 @@
1
+ import { onBeforeUnmount, ref, watch, type Ref } from "vue";
2
+
3
+ /**
4
+ * The floor-plan images of one building map, as object URLs by floor.
5
+ *
6
+ * The images sit behind the session (stored in the map record, not the public
7
+ * file store), so each is fetched with the session header and shown from a
8
+ * local object URL. A plan is fetched again only when its `updatedAt` changes;
9
+ * old URLs are released.
10
+ */
11
+ export default function useFloorPlanImages(map: Ref<Record<string, any> | null | undefined>) {
12
+ const { getFloorPlanImage } = useRobots();
13
+ const urls = ref<Record<number, string>>({});
14
+ const keys = new Map<number, string>();
15
+
16
+ function release(floorNo: number) {
17
+ const u = urls.value[floorNo];
18
+ if (u) URL.revokeObjectURL(u);
19
+ const next = { ...urls.value };
20
+ delete next[floorNo];
21
+ urls.value = next;
22
+ keys.delete(floorNo);
23
+ }
24
+
25
+ watch(
26
+ () => [map.value?._id, (map.value?.floorPlans ?? []).map((p: any) => `${p.floorNo}:${p.updatedAt}`).join("|")],
27
+ async () => {
28
+ const id = map.value?._id ? String(map.value._id) : "";
29
+ const plans: Array<Record<string, any>> = id ? map.value?.floorPlans ?? [] : [];
30
+ const wanted = new Map(plans.map((p) => [Number(p.floorNo), `${id}:${p.updatedAt}`]));
31
+ for (const f of [...keys.keys()]) if (wanted.get(f) !== keys.get(f)) release(f);
32
+ await Promise.all(
33
+ [...wanted].map(async ([floorNo, key]) => {
34
+ if (keys.get(floorNo) === key) return;
35
+ keys.set(floorNo, key);
36
+ try {
37
+ const blob = await getFloorPlanImage(id, floorNo);
38
+ if (keys.get(floorNo) !== key) return;
39
+ urls.value = { ...urls.value, [floorNo]: URL.createObjectURL(blob) };
40
+ } catch {
41
+ // No image drawn for that floor; the next change of the map tries again.
42
+ keys.delete(floorNo);
43
+ }
44
+ }),
45
+ );
46
+ },
47
+ { immediate: true },
48
+ );
49
+
50
+ onBeforeUnmount(() => [...keys.keys()].forEach(release));
51
+ return { urls };
52
+ }
@@ -24,6 +24,12 @@ export default function useRobotBuilding(
24
24
  /** Every robot at the site this app may see, both types where the app shows both. */
25
25
  const all = ref<Array<Record<string, any>>>([]);
26
26
  const map = ref<Record<string, any> | null>(null);
27
+ /**
28
+ * The site's SAVED map - what the editor edits (drawn walls, floor plans, an
29
+ * imported HERO Fleet Core building). At a fleet site `map` is the live fleet
30
+ * building instead, and this is the stored copy beside it.
31
+ */
32
+ const saved = ref<Record<string, any> | null>(null);
27
33
  const names = ref({ siteName: "", clientName: "" });
28
34
  const loading = ref(true);
29
35
  const error = ref("");
@@ -31,13 +37,17 @@ export default function useRobotBuilding(
31
37
  /** The robots of the type on screen (scope isolation: the other type is never drawn). */
32
38
  const rows = computed(() => all.value.filter((r) => r.type === type.value));
33
39
 
34
- async function loadMap() {
40
+ async function loadSaved() {
41
+ const newest = (await listSiteBuildingMaps(site.value, { limit: 1 }))?.items?.[0];
42
+ return newest ? await getBuildingMap(String(newest._id)) : null;
43
+ }
44
+
45
+ async function loadMap(stored: Record<string, any> | null) {
35
46
  if (hasFleetRobots(all.value)) {
36
47
  const building = await getFleetCoreBuilding(site.value).catch(() => null);
37
48
  if (building) return building;
38
49
  }
39
- const newest = (await listSiteBuildingMaps(site.value, { limit: 1 }))?.items?.[0];
40
- return newest ? await getBuildingMap(String(newest._id)) : null;
50
+ return stored;
41
51
  }
42
52
 
43
53
  /** Live doors and lifts of the fleet building; a failed refresh keeps the last reading. */
@@ -63,13 +73,16 @@ export default function useRobotBuilding(
63
73
  if (mine !== run) return;
64
74
  all.value = fleet?.items ?? [];
65
75
  names.value = { siteName: summary?.site?.name ?? "", clientName: summary?.client?.name ?? "" };
66
- const found = await loadMap();
76
+ const stored = await loadSaved();
77
+ const found = await loadMap(stored);
67
78
  if (mine !== run) return;
79
+ saved.value = stored;
68
80
  map.value = found;
69
81
  } catch (e: any) {
70
82
  if (mine !== run) return;
71
83
  all.value = [];
72
84
  map.value = null;
85
+ saved.value = null;
73
86
  error.value = e?.data?.message || "Could not load this building.";
74
87
  } finally {
75
88
  if (mine === run) loading.value = false;
@@ -78,5 +91,5 @@ export default function useRobotBuilding(
78
91
 
79
92
  watch([organization, site], load, { immediate: true });
80
93
 
81
- return { all, rows, map, fleet, names, loading, error, reload: load };
94
+ return { all, rows, map, saved, fleet, names, loading, error, reload: load };
82
95
  }
@@ -364,6 +364,57 @@ export default function useRobots() {
364
364
  });
365
365
  }
366
366
 
367
+ /**
368
+ * The HERO Fleet Core building in stored form (lanes, waypoints, door types,
369
+ * lift cabin doors) for the editor's import. Nothing is saved until the
370
+ * editor saves it.
371
+ */
372
+ function getFleetCoreBuildingData(site: string) {
373
+ return useNuxtApp().$api<Record<string, any>>("/api/robots/fleet-core/building", {
374
+ method: "GET",
375
+ query: { site, appSlug, format: "rmf" },
376
+ });
377
+ }
378
+
379
+ /** The map as a building file or one route graph, as a Blob to download. */
380
+ function exportBuildingMap(mapId: string, format: "building" | "nav", graph?: number) {
381
+ return useNuxtApp().$api<Blob>(`/api/robots/building-maps/${mapId}/export`, {
382
+ method: "GET",
383
+ query: { format, graph, appSlug },
384
+ responseType: "blob",
385
+ });
386
+ }
387
+
388
+ /**
389
+ * One floor's plan image. A Blob, not a URL: the image sits behind the
390
+ * session like the map itself, and an <img src> cannot carry the header.
391
+ */
392
+ function getFloorPlanImage(mapId: string, floorNo: number) {
393
+ return useNuxtApp().$api<Blob>(`/api/robots/building-maps/${mapId}/floor-plans/${floorNo}`, {
394
+ method: "GET",
395
+ responseType: "blob",
396
+ });
397
+ }
398
+
399
+ /** Add / replace (with `image`) or re-scale (without) one floor's plan. Versioned like a map save. */
400
+ function saveFloorPlan(
401
+ mapId: string,
402
+ floorNo: number,
403
+ body: { version: number; image?: string; widthPx: number; heightPx: number; metersPerPixel: number; tx: number; ty: number },
404
+ ) {
405
+ return useNuxtApp().$api<Record<string, any>>(`/api/robots/building-maps/${mapId}/floor-plans/${floorNo}`, {
406
+ method: "PUT",
407
+ body,
408
+ });
409
+ }
410
+
411
+ function removeFloorPlan(mapId: string, floorNo: number, version: number) {
412
+ return useNuxtApp().$api<Record<string, any>>(`/api/robots/building-maps/${mapId}/floor-plans/${floorNo}`, {
413
+ method: "DELETE",
414
+ query: { version },
415
+ });
416
+ }
417
+
367
418
  function deleteBuildingMap(mapId: string) {
368
419
  return useNuxtApp().$api<Record<string, any>>(`/api/robots/building-maps/${mapId}`, {
369
420
  method: "DELETE",
@@ -414,6 +465,11 @@ export default function useRobots() {
414
465
  createBuildingMap,
415
466
  updateBuildingMap,
416
467
  deleteBuildingMap,
468
+ getFleetCoreBuildingData,
469
+ exportBuildingMap,
470
+ getFloorPlanImage,
471
+ saveFloorPlan,
472
+ removeFloorPlan,
417
473
  getLiftQueue,
418
474
  requestLift,
419
475
  releaseLiftSession,
@@ -9,6 +9,8 @@ export default function useVerification() {
9
9
  siteId = "",
10
10
  orgId = "",
11
11
  limit = 20,
12
+ // "seven365": only the invitations Seven365 sent (the console's lists).
13
+ origin = "",
12
14
  } = {}): Promise<{
13
15
  items: TMiniVerification[];
14
16
  pages: number;
@@ -16,7 +18,11 @@ export default function useVerification() {
16
18
  }> {
17
19
  return useNuxtApp().$api("/api/verifications", {
18
20
  method: "GET",
19
- query: { status, search, page, type, email, app, siteId, orgId, limit },
21
+ query: {
22
+ status, search, page, type, email, app, siteId, orgId, limit,
23
+ // Sent only when asked for: an older API refuses a key it does not know.
24
+ ...(origin && { origin }),
25
+ },
20
26
  });
21
27
  }
22
28
 
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.45.0",
5
+ "version": "4.47.0",
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.",
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Floor plans and HERO Fleet Core building files in the map editor - the pure
3
+ * rules (placement, scale by measurement, import body, file names). The
4
+ * components only draw what these return.
5
+ *
6
+ * A floor plan is an image stored privately in the building map (never the
7
+ * public file store) and placed in the HERO plan frame (40 x 26 m, the same
8
+ * frame every drawn wall, door and lift uses): its top-left corner at
9
+ * (`tx`, `ty`) metres, `metersPerPixel` from a measured distance.
10
+ */
11
+
12
+ /** The HERO plan frame - `utils/robot-building.ts` PLAN_W_M / PLAN_H_M (decision 30). */
13
+ export const FRAME_W_M = 40;
14
+ export const FRAME_H_M = 26;
15
+ /** What the API stores per image, after compression here. */
16
+ export const FLOOR_PLAN_MAX_BYTES = 1_000_000;
17
+ /** Longest side the browser compresses a plan to. */
18
+ export const FLOOR_PLAN_MAX_PX = 2400;
19
+ export const FLOOR_PLAN_TYPES = ["image/png", "image/jpeg", "image/webp"];
20
+
21
+ export type TFloorPlan = { floorNo: number; widthPx: number; heightPx: number; metersPerPixel: number; tx: number; ty: number; updatedAt?: string };
22
+ export type TPlanPoint = { x: number; y: number };
23
+
24
+ const r6 = (v: number) => Math.round(v * 1e6) / 1e6;
25
+
26
+ export const floorPlanFor = (map: Record<string, any> | null | undefined, floorNo: number): TFloorPlan | null =>
27
+ ((map?.floorPlans ?? []) as TFloorPlan[]).find((p) => p.floorNo === floorNo) ?? null;
28
+
29
+ /** A new plan fitted inside the frame, top-left aligned, aspect kept. */
30
+ export function fitPlan(widthPx: number, heightPx: number): Pick<TFloorPlan, "metersPerPixel" | "tx" | "ty"> {
31
+ return { metersPerPixel: r6(Math.min(FRAME_W_M / widthPx, FRAME_H_M / heightPx)), tx: 0, ty: 0 };
32
+ }
33
+
34
+ /**
35
+ * A replacement image keeps the building's real size and place: same width in
36
+ * metres, same corner. (A re-drawn plan of the same floor should not need
37
+ * measuring again.)
38
+ */
39
+ export function replacePlacement(old: TFloorPlan | null, widthPx: number, heightPx: number) {
40
+ if (!old) return fitPlan(widthPx, heightPx);
41
+ return { metersPerPixel: r6((old.widthPx * old.metersPerPixel) / widthPx), tx: old.tx, ty: old.ty };
42
+ }
43
+
44
+ /** Where the image sits over the plan, as percentages of the frame (the editor draws everything in %). */
45
+ export function planBox(p: Pick<TFloorPlan, "widthPx" | "heightPx" | "metersPerPixel" | "tx" | "ty">) {
46
+ return {
47
+ left: (p.tx / FRAME_W_M) * 100,
48
+ top: (p.ty / FRAME_H_M) * 100,
49
+ width: ((p.widthPx * p.metersPerPixel) / FRAME_W_M) * 100,
50
+ height: ((p.heightPx * p.metersPerPixel) / FRAME_H_M) * 100,
51
+ };
52
+ }
53
+
54
+ /** Metres between two points given in plan percentages. */
55
+ export function planDistanceM(a: TPlanPoint, b: TPlanPoint) {
56
+ return Math.hypot(((b.x - a.x) / 100) * FRAME_W_M, ((b.y - a.y) / 100) * FRAME_H_M);
57
+ }
58
+
59
+ /**
60
+ * Scale by measurement: two points the person clicked on the plan (in plan %)
61
+ * and the real distance between them. The image grows or shrinks about its
62
+ * top-left corner. Null (with a reason) when it cannot be done.
63
+ */
64
+ export function rescaleByMeasure(p: TFloorPlan, a: TPlanPoint, b: TPlanPoint, realM: number): { plan: TFloorPlan } | { error: string } {
65
+ const drawn = planDistanceM(a, b);
66
+ if (!(realM > 0) || !Number.isFinite(realM)) return { error: "Enter the real distance in metres (more than 0)." };
67
+ if (drawn < 0.05) return { error: "Pick two points further apart." };
68
+ const metersPerPixel = r6(p.metersPerPixel * (realM / drawn));
69
+ if (metersPerPixel < 0.0001 || metersPerPixel > 10) return { error: "That distance gives an impossible scale. Check the two points and the number." };
70
+ return { plan: { ...p, metersPerPixel } };
71
+ }
72
+
73
+ /** "1 px = 4.2 cm · plan 33.6 x 25.2 m" */
74
+ export function scaleText(p: TFloorPlan | null) {
75
+ if (!p) return "";
76
+ const cm = p.metersPerPixel * 100;
77
+ const w = p.widthPx * p.metersPerPixel;
78
+ const h = p.heightPx * p.metersPerPixel;
79
+ const big = w > FRAME_W_M + 0.05 || h > FRAME_H_M + 0.05;
80
+ return `1 px = ${cm >= 10 ? cm.toFixed(0) : cm.toFixed(1)} cm · plan ${w.toFixed(1)} × ${h.toFixed(1)} m${big ? " (larger than the 40 × 26 m drawing area - the edge is cut off)" : ""}`;
81
+ }
82
+
83
+ /** Plain-language reason a picked file cannot be used, or null. */
84
+ export function floorPlanFileProblem(file: { type: string; size: number }) {
85
+ if (!FLOOR_PLAN_TYPES.includes(file.type)) return "Choose a PNG, JPEG or WebP image of the floor plan.";
86
+ if (file.size > 40_000_000) return "That image is too large to open (40 MB at most).";
87
+ return null;
88
+ }
89
+
90
+ /** The size to compress to: longest side at most FLOOR_PLAN_MAX_PX, never upscaled. */
91
+ export function compressSize(widthPx: number, heightPx: number, maxPx = FLOOR_PLAN_MAX_PX) {
92
+ const s = Math.min(1, maxPx / Math.max(widthPx, heightPx));
93
+ return { width: Math.max(1, Math.round(widthPx * s)), height: Math.max(1, Math.round(heightPx * s)) };
94
+ }
95
+
96
+ /**
97
+ * The save body for an import from HERO Fleet Core: the fleet's building is the
98
+ * truth (`rmf`) and the server derives the drawn walls, doors and lifts from
99
+ * it. Floor plans and the map's name stay.
100
+ */
101
+ export function fleetImportBody(map: Record<string, any> | null | undefined, rmf: Record<string, any>, siteName = "") {
102
+ return { name: String(map?.name || `${siteName || "Site"} building`).slice(0, 60), levels: [], doors: [], lifts: [], rmf };
103
+ }
104
+
105
+ /** "Tower B" -> "tower-b.building.yaml" / "tower-b-nav-0.yaml" - the names the API sends too. */
106
+ export function exportFileName(name: string, format: "building" | "nav", graph = 0) {
107
+ const slug = (name || "building").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "building";
108
+ return format === "building" ? `${slug}.building.yaml` : `${slug}-nav-${graph}.yaml`;
109
+ }
110
+
111
+ /** "3 floors · 42 lanes · 18 named points · 19 doors · 2 lifts" for a stored rmf part. */
112
+ export function rmfLine(rmf: Record<string, any> | null | undefined) {
113
+ if (!rmf?.levels) return "";
114
+ const levels = rmf.levels as Array<Record<string, any>>;
115
+ const n = (f: (l: Record<string, any>) => number) => levels.reduce((s, l) => s + f(l), 0);
116
+ const parts = [
117
+ `${levels.length} floor${levels.length === 1 ? "" : "s"}`,
118
+ `${n((l) => l.lanes?.length ?? 0)} lanes`,
119
+ `${n((l) => (l.vertices ?? []).filter((v: any) => v.name).length)} named points`,
120
+ `${n((l) => l.doors?.length ?? 0)} doors`,
121
+ `${rmf.lifts?.length ?? 0} lifts`,
122
+ ];
123
+ return parts.join(" · ");
124
+ }
125
+
126
+ /** The route graphs a download can offer. */
127
+ export const rmfGraphs = (rmf: Record<string, any> | null | undefined): number[] =>
128
+ ((rmf?.graphs ?? []) as Array<{ idx: number }>).map((g) => g.idx).sort((a, b) => a - b);
129
+
130
+ /** The Add robot dialog's credentials note - it says "encrypted" only when this server really encrypts. */
131
+ export const credentialsNote = (encrypted: boolean) =>
132
+ encrypted ? "Credentials are stored encrypted and never shown again after saving." : "Credentials are never shown again after saving.";