@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.
- package/.changeset/camera-wall-gated-endpoint.md +47 -0
- package/components/CameraWall.vue +19 -44
- package/components/HidIntercomManagement.vue +184 -60
- package/components/HidQrCodeConfiguration.vue +195 -31
- package/components/HidUserEnrollment.vue +94 -2
- package/components/SiteSettings.vue +4 -2
- package/composables/useHidAmico.ts +64 -3
- package/composables/useSiteSettings.ts +28 -0
- package/package.json +1 -1
- package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
- package/types/site.d.ts +6 -0
- package/utils/camera-wall.test.ts +28 -0
- package/utils/camera-wall.ts +18 -0
- package/middleware/member.ts +0 -4
|
@@ -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 {
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
//
|
|
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
|
-
|
|
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="
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
<
|
|
412
|
-
<
|
|
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
|
-
() =>
|
|
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:
|
|
1188
|
-
aor:
|
|
1189
|
-
authorizationUsername:
|
|
1190
|
-
authorizationPassword:
|
|
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:
|
|
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
|
|
1496
|
-
|
|
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
|
}
|