@7365admin1/layer-common 3.1.4-staging.55 → 3.1.4-staging.57

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.
@@ -0,0 +1,47 @@
1
+ ---
2
+ "@7365admin1/layer-common": patch
3
+ ---
4
+
5
+ Draw the camera wall from the gated wall endpoint, and show the server's own
6
+ reason on a tile.
7
+
8
+ `CameraWall` fetched its cameras from `GET /api/site-cameras` - the Settings
9
+ panel's paginated CRUD list, which took its site from an optional query
10
+ parameter and checked nothing about the caller. Any signed-in user from any
11
+ organisation could read every camera in the estate through it, `host` and
12
+ `username` included.
13
+
14
+ It now calls `GET /api/site-cameras/site/:siteId/wall`, which already exists,
15
+ which the React Native monitoring app was built against, and which is
16
+ authorised server-side: the caller must be a member of the site, of its owning
17
+ organisation, or work for an organisation actively engaged to serve it. That
18
+ endpoint also returns `type: "ip"` cameras only and each camera's capability
19
+ descriptor, so a wall cannot be handed an ANPR unit and a tile can explain
20
+ itself.
21
+
22
+ Two consequences worth stating:
23
+
24
+ - **The pager is gone.** The wall endpoint returns the site's cameras in one
25
+ answer. Paging a video wall was an artefact of borrowing the CRUD list.
26
+ - **A tile now prefers the server's `unavailableReason`** over the reason it
27
+ used to work out itself. The server knows things the browser cannot - chiefly
28
+ "No recorder is configured for this camera's relay", which is the true answer
29
+ for every camera in the estate until the API host is configured, and which the
30
+ wall used to replace with its own guess. Offline still beats everything, since
31
+ it explains every tile at once.
32
+
33
+ No permission gate was invented here. Each application still gates its own menu
34
+ entry; this component draws what it is given, and the server decides.
35
+
36
+ Two smaller things in the same area:
37
+
38
+ - **A refused wall now says so.** The catch-all message told every failure to
39
+ "check your connection", which sends somebody who simply may not see that
40
+ site off to debug their wifi. A 401/403/404 now reads "You do not have access
41
+ to this site's cameras." The server answers "not yours" and "does not exist"
42
+ identically, so this wording does not distinguish them either.
43
+ - **`middleware/member.ts` is removed.** It read a cookie into an unused
44
+ variable and did nothing else - a file named like a membership gate that was
45
+ not one. No page in any of the eleven web apps referenced it. The real check
46
+ is `plugins/secure-member.client.ts`, driven by `memberOnly` page meta, which
47
+ is untouched.
@@ -40,16 +40,6 @@
40
40
 
41
41
  <div class="vms__spacer" />
42
42
 
43
- <div v-if="pages > 1" class="vms__pager">
44
- <button type="button" class="vms__tool" :disabled="page === 1" @click="page--">
45
- Prev
46
- </button>
47
- <span class="vms__range">{{ pageRange || `Page ${page} of ${pages}` }}</span>
48
- <button type="button" class="vms__tool" :disabled="page >= pages" @click="page++">
49
- Next
50
- </button>
51
- </div>
52
-
53
43
  <button
54
44
  type="button"
55
45
  class="vms__tool"
@@ -225,12 +215,9 @@ const props = defineProps<{ site: string }>();
225
215
 
226
216
  /* ---------------------------------------------------------------- the data */
227
217
 
228
- const { getAllSiteCameras } = useSiteSettings();
218
+ const { getSiteWall } = useSiteSettings();
229
219
 
230
220
  const cameras = ref<TWallCamera[]>([]);
231
- const page = ref(1);
232
- const pages = ref(0);
233
- const pageRange = ref("");
234
221
  const loading = ref(true);
235
222
  const failed = ref("");
236
223
 
@@ -238,32 +225,32 @@ async function load() {
238
225
  loading.value = true;
239
226
  failed.value = "";
240
227
  try {
241
- const res = await getAllSiteCameras({
242
- site: props.site,
243
- // The server is asked for IP cameras only, and `wallCameras` filters
244
- // again on the way in: an ANPR unit belongs to visitor and vehicle
245
- // management and must never appear on a monitoring wall.
246
- type: "ip",
247
- page: page.value,
248
- });
249
- cameras.value = wallCameras(res?.items);
250
- pages.value = Number(res?.pages) || 0;
251
- pageRange.value = String(res?.pageRange || "");
252
- } catch {
253
- // The reason an API call failed is rarely something an operator can act on,
254
- // and the raw message is often a stack trace. Say what is true and offer
255
- // the one useful action.
228
+ // The site's wall, from the endpoint built for it. `wallCameras` still
229
+ // filters on the way in — the server already returns `type: "ip"` only, and
230
+ // an ANPR unit must never reach a monitoring wall even if that ever changes.
231
+ const res = await getSiteWall(props.site);
232
+ cameras.value = wallCameras(res?.cameras);
233
+ } catch (error: any) {
234
+ // The raw message is often a stack trace and rarely something an operator
235
+ // can act on — but "you may not see this site" and "your connection
236
+ // dropped" are genuinely different problems with different next steps, and
237
+ // telling a refused caller to check their connection sends them off to
238
+ // debug their wifi. The server answers a site that is not the caller's the
239
+ // same way as a site that does not exist, on purpose, so this wording does
240
+ // not distinguish them either.
256
241
  cameras.value = [];
257
- failed.value = "The list of cameras did not load. Check your connection and try again.";
242
+ const status = Number(error?.statusCode ?? error?.response?.status ?? 0);
243
+ failed.value =
244
+ status === 401 || status === 403 || status === 404
245
+ ? "You do not have access to this site's cameras."
246
+ : "The list of cameras did not load. Check your connection and try again.";
258
247
  } finally {
259
248
  loading.value = false;
260
249
  }
261
250
  }
262
251
 
263
252
  onMounted(load);
264
- watch(page, load);
265
253
  watch(() => props.site, () => {
266
- page.value = 1;
267
254
  selectedIds.value = [];
268
255
  load();
269
256
  });
@@ -433,18 +420,6 @@ async function toggleFullscreen() {
433
420
  color: var(--vms-text-dim);
434
421
  }
435
422
 
436
- .vms__pager {
437
- display: flex;
438
- align-items: center;
439
- gap: 8px;
440
- }
441
-
442
- .vms__range {
443
- font-size: 12px;
444
- color: var(--vms-text-dim);
445
- white-space: nowrap;
446
- }
447
-
448
423
  .vms__body {
449
424
  display: flex;
450
425
  min-height: 0;
@@ -386,54 +386,28 @@
386
386
  <v-card-text class="web-phone-body">
387
387
  <div class="web-phone-settings">
388
388
  <div class="web-phone-section-title">SIP Account</div>
389
- <div class="call-label">WebSocket Server <span>*</span></div>
390
- <v-text-field
391
- v-model="webPhoneForm.webSocketServer"
392
- placeholder="wss://sip.example.com/ws"
393
- variant="outlined"
394
- density="compact"
395
- hide-details="auto"
396
- class="call-input"
397
- :disabled="webPhone.isRegistered.value"
398
- />
399
- <div class="call-label">SIP Address <span>*</span></div>
400
- <v-text-field
401
- v-model="webPhoneForm.aor"
402
- placeholder="operator@example.com"
403
- variant="outlined"
404
- density="compact"
405
- hide-details="auto"
406
- class="call-input"
407
- :disabled="webPhone.isRegistered.value"
408
- />
409
- <div class="form-two-col">
389
+ <div v-if="sipAccountLoading" class="sip-account-state">
390
+ <v-progress-circular indeterminate color="primary" size="30" width="3" />
391
+ <span>Preparing your private SIP account...</span>
392
+ </div>
393
+ <div v-else-if="sipAccountError" class="sip-account-state is-error">
394
+ <v-icon icon="mdi-alert-circle-outline" size="28" />
395
+ <span>{{ sipAccountError }}</span>
396
+ <v-btn
397
+ class="text-none sip-account-retry"
398
+ variant="outlined"
399
+ prepend-icon="mdi-refresh"
400
+ @click="loadSipAccount"
401
+ >
402
+ Retry
403
+ </v-btn>
404
+ </div>
405
+ <div v-else-if="sipAccount" class="sip-account-summary">
410
406
  <div>
411
- <div class="call-label">Username <span>*</span></div>
412
- <v-text-field
413
- v-model="webPhoneForm.username"
414
- placeholder="Username"
415
- variant="outlined"
416
- density="compact"
417
- hide-details="auto"
418
- class="call-input"
419
- :disabled="webPhone.isRegistered.value"
420
- />
421
- </div>
422
- <div>
423
- <div class="call-label">Password <span>*</span></div>
424
- <v-text-field
425
- v-model="webPhoneForm.password"
426
- placeholder="Password"
427
- :type="showWebPhonePassword ? 'text' : 'password'"
428
- :append-inner-icon="showWebPhonePassword ? 'mdi-eye-off' : 'mdi-eye'"
429
- variant="outlined"
430
- density="compact"
431
- hide-details="auto"
432
- class="call-input"
433
- :disabled="webPhone.isRegistered.value"
434
- @click:append-inner="showWebPhonePassword = !showWebPhonePassword"
435
- />
407
+ <span class="sip-account-label">Assigned extension</span>
408
+ <strong class="sip-account-extension">{{ sipAccount.extension }}</strong>
436
409
  </div>
410
+ <span class="sip-account-address">{{ sipAccount.sipAddress }}</span>
437
411
  </div>
438
412
  <div class="call-toggle-row web-video-toggle">
439
413
  <span>Enable video</span>
@@ -452,6 +426,7 @@
452
426
  variant="flat"
453
427
  prepend-icon="mdi-lan-connect"
454
428
  :loading="webPhoneConnecting"
429
+ :disabled="!sipAccount || sipAccountLoading"
455
430
  @click="connectWebPhone"
456
431
  >
457
432
  Connect Web Phone
@@ -613,6 +588,10 @@
613
588
 
614
589
  <script setup lang="ts">
615
590
  const props = defineProps({
591
+ org: {
592
+ type: String,
593
+ required: true,
594
+ },
616
595
  site: {
617
596
  type: String,
618
597
  required: true,
@@ -631,6 +610,19 @@ type ContactRow = {
631
610
  dialCode: string;
632
611
  };
633
612
 
613
+ type SipAccount = {
614
+ orgId: string;
615
+ site: string;
616
+ userId: string;
617
+ extension: string;
618
+ username: string;
619
+ password: string;
620
+ sipAddress: string;
621
+ websocketUrl: string;
622
+ status: "active";
623
+ provisionedAt: string;
624
+ };
625
+
634
626
  type DetailItem = {
635
627
  label: string;
636
628
  value?: string | number | boolean;
@@ -678,6 +670,7 @@ const {
678
670
  getIntercomStatus,
679
671
  makeIntercomCall,
680
672
  finalizeIntercomCall,
673
+ ensureSipAccount,
681
674
  } = useHidAmico();
682
675
 
683
676
  const activeTab = ref<IntercomTab>("intercom");
@@ -696,9 +689,11 @@ const limit = 20;
696
689
  const selectedRow = ref<IntercomRow | null>(null);
697
690
  const selectedContact = ref<ContactRow | null>(null);
698
691
  const showSipPassword = ref(false);
699
- const showWebPhonePassword = ref(false);
700
692
  const webPhoneConnecting = ref(false);
701
693
  const webPhoneActionLoading = ref(false);
694
+ const sipAccount = ref<SipAccount | null>(null);
695
+ const sipAccountLoading = ref(false);
696
+ const sipAccountError = ref("");
702
697
  const localVideoEl = ref<HTMLVideoElement | null>(null);
703
698
  const remoteVideoEl = ref<HTMLVideoElement | null>(null);
704
699
  const remoteAudioEl = ref<HTMLAudioElement | null>(null);
@@ -787,10 +782,6 @@ const makeCallForm = reactive({
787
782
  });
788
783
 
789
784
  const webPhoneForm = reactive({
790
- webSocketServer: "",
791
- aor: "",
792
- username: "",
793
- password: "",
794
785
  destination: "",
795
786
  video: false,
796
787
  });
@@ -926,8 +917,14 @@ const webPhoneCallIcon = computed(() => {
926
917
  });
927
918
 
928
919
  watch(
929
- () => props.site,
930
- () => reload(),
920
+ [() => props.org, () => props.site],
921
+ () => {
922
+ if (webPhone.isRegistered.value) void webPhone.disconnect();
923
+ sipAccount.value = null;
924
+ sipAccountError.value = "";
925
+ webPhoneForm.destination = "";
926
+ void reload();
927
+ },
931
928
  { immediate: true },
932
929
  );
933
930
 
@@ -1178,16 +1175,42 @@ function openWebPhoneDialog(row?: IntercomRow) {
1178
1175
  }
1179
1176
  }
1180
1177
  webPhoneDialog.value = true;
1178
+ if (!sipAccount.value && !sipAccountLoading.value) void loadSipAccount();
1179
+ }
1180
+
1181
+ async function loadSipAccount() {
1182
+ if (!props.org || !props.site) return;
1183
+
1184
+ sipAccountLoading.value = true;
1185
+ sipAccountError.value = "";
1186
+ try {
1187
+ const response = await ensureSipAccount(props.site, props.org);
1188
+ sipAccount.value = normalizeSipAccount(response);
1189
+ } catch (error: unknown) {
1190
+ sipAccount.value = null;
1191
+ sipAccountError.value = getErrorMessage(error);
1192
+ } finally {
1193
+ sipAccountLoading.value = false;
1194
+ }
1181
1195
  }
1182
1196
 
1183
1197
  async function connectWebPhone() {
1198
+ const account = sipAccount.value;
1199
+ if (!account) {
1200
+ await loadSipAccount();
1201
+ if (!sipAccount.value) return;
1202
+ }
1203
+
1204
+ const activeAccount = sipAccount.value;
1205
+ if (!activeAccount) return;
1206
+
1184
1207
  webPhoneConnecting.value = true;
1185
1208
  try {
1186
1209
  await webPhone.connect({
1187
- webSocketServer: webPhoneForm.webSocketServer,
1188
- aor: webPhoneForm.aor,
1189
- authorizationUsername: webPhoneForm.username,
1190
- authorizationPassword: webPhoneForm.password,
1210
+ webSocketServer: activeAccount.websocketUrl,
1211
+ aor: activeAccount.sipAddress,
1212
+ authorizationUsername: activeAccount.username,
1213
+ authorizationPassword: activeAccount.password,
1191
1214
  displayName: "iService365 Web",
1192
1215
  video: webPhoneForm.video,
1193
1216
  }, {
@@ -1196,7 +1219,7 @@ async function connectWebPhone() {
1196
1219
  remoteAudio: remoteAudioEl.value,
1197
1220
  });
1198
1221
  showMessage("Web phone connected and ready to receive calls.", "success");
1199
- } catch (error: any) {
1222
+ } catch (error: unknown) {
1200
1223
  showMessage(getErrorMessage(error), "error");
1201
1224
  } finally {
1202
1225
  webPhoneConnecting.value = false;
@@ -1492,8 +1515,42 @@ function showMessage(text: string, type: MessageType = "info") {
1492
1515
  message.type = type;
1493
1516
  }
1494
1517
 
1495
- function getErrorMessage(error: any) {
1496
- return error?.data?.message || error?.response?._data?.message || error?.message || "HID intercom request failed.";
1518
+ function normalizeSipAccount(response: unknown): SipAccount {
1519
+ const responseRecord = toRecord(response);
1520
+ const payload = toRecord(responseRecord.data || responseRecord);
1521
+ const requiredFields = ["extension", "username", "password", "sipAddress", "websocketUrl"] as const;
1522
+
1523
+ if (requiredFields.some((field) => !toText(payload[field]))) {
1524
+ throw new Error("The SIP account response is incomplete. Please try again.");
1525
+ }
1526
+
1527
+ return {
1528
+ orgId: toText(payload.orgId, props.org),
1529
+ site: toText(payload.site, props.site),
1530
+ userId: toText(payload.userId),
1531
+ extension: toText(payload.extension),
1532
+ username: toText(payload.username),
1533
+ password: toText(payload.password),
1534
+ sipAddress: toText(payload.sipAddress),
1535
+ websocketUrl: toText(payload.websocketUrl),
1536
+ status: "active",
1537
+ provisionedAt: toText(payload.provisionedAt),
1538
+ };
1539
+ }
1540
+
1541
+ function getErrorMessage(error: unknown) {
1542
+ const errorRecord = toRecord(error);
1543
+ const data = toRecord(errorRecord.data);
1544
+ const response = toRecord(errorRecord.response);
1545
+ const responseData = toRecord(response._data);
1546
+ return toText(data.message)
1547
+ || toText(responseData.message)
1548
+ || toText(errorRecord.message)
1549
+ || "HID intercom request failed.";
1550
+ }
1551
+
1552
+ function toRecord(value: unknown): Record<string, unknown> {
1553
+ return typeof value === "object" && value !== null ? value as Record<string, unknown> : {};
1497
1554
  }
1498
1555
 
1499
1556
  function toText(value: unknown, fallback = "") {
@@ -2226,6 +2283,73 @@ export default {
2226
2283
  font-weight: 700;
2227
2284
  }
2228
2285
 
2286
+ .sip-account-state {
2287
+ display: flex;
2288
+ min-height: 126px;
2289
+ align-items: center;
2290
+ justify-content: center;
2291
+ padding: 18px;
2292
+ border: 1px solid #dce2e8;
2293
+ border-radius: 6px;
2294
+ color: #5d6673;
2295
+ flex-direction: column;
2296
+ font-size: 12px;
2297
+ gap: 12px;
2298
+ text-align: center;
2299
+ }
2300
+
2301
+ .sip-account-state.is-error {
2302
+ border-color: #f0c7c7;
2303
+ background: #fff8f8;
2304
+ color: #a92222;
2305
+ }
2306
+
2307
+ .sip-account-retry {
2308
+ min-width: 104px;
2309
+ border-color: #aeb8c2;
2310
+ color: #344054;
2311
+ }
2312
+
2313
+ .sip-account-summary {
2314
+ display: grid;
2315
+ min-height: 96px;
2316
+ align-items: center;
2317
+ padding: 16px;
2318
+ border: 1px solid #dce2e8;
2319
+ border-radius: 6px;
2320
+ background: #f8fafc;
2321
+ grid-template-columns: minmax(0, 1fr) auto;
2322
+ gap: 18px;
2323
+ }
2324
+
2325
+ .sip-account-summary > div {
2326
+ display: flex;
2327
+ min-width: 0;
2328
+ flex-direction: column;
2329
+ gap: 4px;
2330
+ }
2331
+
2332
+ .sip-account-label {
2333
+ color: #667085;
2334
+ font-size: 11px;
2335
+ }
2336
+
2337
+ .sip-account-extension {
2338
+ color: #0d1720;
2339
+ font-size: 24px;
2340
+ line-height: 1.1;
2341
+ }
2342
+
2343
+ .sip-account-address {
2344
+ max-width: 170px;
2345
+ overflow: hidden;
2346
+ color: #536273;
2347
+ font-size: 11px;
2348
+ text-align: right;
2349
+ text-overflow: ellipsis;
2350
+ white-space: nowrap;
2351
+ }
2352
+
2229
2353
  .web-video-toggle {
2230
2354
  margin-top: 12px;
2231
2355
  }