locadot 2.1.0 → 2.3.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.
@@ -348,11 +348,16 @@ td.badges .badge + .badge { margin-left: 4px; }
348
348
  .btn:hover:not(:disabled) { background: var(--surface-2); border-color: var(--muted-dim); }
349
349
  .btn:disabled { opacity: 0.5; cursor: not-allowed; }
350
350
  .btn-sm { height: 28px; padding: 0 10px; font-size: 12px; }
351
+ .btn-sm.btn-icon { width: 28px; padding: 0; color: var(--muted); }
352
+ .btn-icon svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
353
+ .btn-icon.is-set { color: var(--accent); border-color: transparent; background: var(--accent-bg); }
351
354
  .btn-block { width: 100%; }
352
355
  .btn-primary { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
353
356
  .btn-primary:hover:not(:disabled) { background: var(--accent-strong); border-color: var(--accent-strong); }
354
357
  .btn-danger { border-color: var(--down); color: var(--down); background: transparent; }
355
358
  .btn-danger:hover:not(:disabled) { background: var(--down-bg); border-color: var(--down); }
359
+ .modal-foot .btn-danger { background: var(--down); border-color: var(--down); color: #fff; }
360
+ .modal-foot .btn-danger:hover:not(:disabled) { background: var(--down); filter: brightness(0.92); }
356
361
  .btn-ghost { background: transparent; border-color: transparent; box-shadow: none; color: var(--muted); }
357
362
  .btn-ghost:hover:not(:disabled) { background: var(--surface-2); border-color: transparent; color: var(--fg); }
358
363
  .btn-ghost-danger { background: transparent; border-color: transparent; box-shadow: none; color: var(--muted); }
@@ -575,6 +580,8 @@ td .inline-check + .inline-check { margin-top: 4px; }
575
580
  .inline-check { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
576
581
  .tunnel-cell { white-space: normal; }
577
582
  .tunnel-status { display: flex; align-items: center; gap: 6px; max-width: 240px; }
583
+ .tunnel-login { display: flex; flex-direction: column; gap: 2px; font-size: 12px; white-space: nowrap; }
584
+ .tunnel-login a { font-weight: 500; }
578
585
  .tunnel-status a { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 180px; display: inline-block; vertical-align: middle; font-size: 12px; }
579
586
  .tunnel-error { color: var(--down); cursor: help; }
580
587
  .tunnel-error-text {
@@ -729,6 +736,139 @@ details.card[open] summary { border-bottom: 1px solid var(--border); border-bott
729
736
  margin: 0;
730
737
  }
731
738
 
739
+ /* ---------- allow dialog ---------- */
740
+ #allow-dialog {
741
+ width: min(520px, calc(100vw - 32px));
742
+ padding: 0;
743
+ border: 1px solid var(--border);
744
+ border-radius: var(--radius);
745
+ background: var(--surface);
746
+ color: var(--fg);
747
+ box-shadow: var(--shadow-md);
748
+ }
749
+ #allow-dialog::backdrop { background: rgba(0, 0, 0, 0.45); }
750
+ .modal-head {
751
+ display: flex;
752
+ align-items: flex-start;
753
+ justify-content: space-between;
754
+ gap: 12px;
755
+ padding: 16px 20px;
756
+ border-bottom: 1px solid var(--border);
757
+ }
758
+ .modal-title { font-size: 15px; font-weight: 600; margin: 0; letter-spacing: -0.01em; }
759
+ .modal-sub { margin: 2px 0 0; font-size: 12.5px; color: var(--muted); }
760
+ .modal-close { width: 28px; height: 28px; padding: 0; border-radius: 999px; flex-shrink: 0; }
761
+ .modal-close svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
762
+ .modal-body { padding: 20px; display: flex; flex-direction: column; gap: 14px; }
763
+ .modal-desc { margin: 0; font-size: 13px; color: var(--muted); line-height: 1.5; }
764
+ .modal-warn {
765
+ font-size: 12.5px;
766
+ color: var(--warn);
767
+ background: var(--warn-bg);
768
+ border: 1px solid var(--warn);
769
+ border-radius: var(--radius-sm);
770
+ padding: 8px 12px;
771
+ line-height: 1.5;
772
+ }
773
+ .allow-list { display: flex; flex-direction: column; gap: 6px; max-height: 220px; overflow-y: auto; }
774
+ .allow-row {
775
+ display: flex;
776
+ align-items: center;
777
+ justify-content: space-between;
778
+ gap: 10px;
779
+ padding: 6px 10px;
780
+ border: 1px solid var(--border);
781
+ border-radius: var(--radius-sm);
782
+ background: var(--surface-2);
783
+ }
784
+ .allow-row span { font-size: 13px; word-break: break-all; }
785
+ .allow-remove {
786
+ display: inline-flex;
787
+ align-items: center;
788
+ justify-content: center;
789
+ width: 22px;
790
+ height: 22px;
791
+ padding: 0;
792
+ font-size: 15px;
793
+ line-height: 1;
794
+ border-radius: var(--radius-xs);
795
+ border: 1px solid transparent;
796
+ background: transparent;
797
+ color: var(--muted);
798
+ cursor: pointer;
799
+ flex-shrink: 0;
800
+ }
801
+ .allow-remove:hover { color: var(--down); background: var(--down-bg); border-color: transparent; }
802
+ .allow-empty { font-size: 13px; padding: 4px 0; }
803
+ .allow-add-row { display: flex; gap: 8px; }
804
+ .allow-add-row .input { flex: 1; }
805
+ .allow-add-row .btn { height: auto; align-self: stretch; padding: 0 14px; }
806
+ .modal-hint { margin: 0; font-size: 12px; color: var(--muted-dim); }
807
+ .modal-foot {
808
+ display: flex;
809
+ justify-content: flex-end;
810
+ gap: 8px;
811
+ padding: 16px 20px;
812
+ border-top: 1px solid var(--border);
813
+ }
814
+
815
+ /* ---------- confirm / prompt / share dialogs ---------- */
816
+ #confirm-dialog, #prompt-dialog, #share-dialog {
817
+ padding: 0;
818
+ border: 1px solid var(--border);
819
+ border-radius: var(--radius);
820
+ background: var(--surface);
821
+ color: var(--fg);
822
+ box-shadow: var(--shadow-md);
823
+ }
824
+ #confirm-dialog::backdrop, #prompt-dialog::backdrop, #share-dialog::backdrop { background: rgba(0, 0, 0, 0.45); }
825
+ #confirm-dialog { width: min(420px, calc(100vw - 32px)); }
826
+ #prompt-dialog { width: min(420px, calc(100vw - 32px)); }
827
+ #share-dialog { width: min(480px, calc(100vw - 32px)); }
828
+ .share-options { display: flex; flex-direction: column; gap: 10px; }
829
+ .option-card {
830
+ display: flex;
831
+ align-items: flex-start;
832
+ gap: 12px;
833
+ width: 100%;
834
+ text-align: left;
835
+ padding: 14px;
836
+ border: 1px solid var(--border);
837
+ border-radius: var(--radius);
838
+ background: var(--surface-2);
839
+ color: inherit;
840
+ font: inherit;
841
+ cursor: pointer;
842
+ }
843
+ .option-card:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-bg); }
844
+ .option-card[aria-checked="true"] { border-color: var(--accent); background: var(--accent-bg); }
845
+ .option-dot {
846
+ width: 16px;
847
+ height: 16px;
848
+ margin-top: 2px;
849
+ flex-shrink: 0;
850
+ border-radius: 999px;
851
+ border: 1.5px solid var(--border-strong);
852
+ background: var(--surface);
853
+ position: relative;
854
+ }
855
+ .option-card[aria-checked="true"] .option-dot { border-color: var(--accent); }
856
+ .option-card[aria-checked="true"] .option-dot::after {
857
+ content: "";
858
+ position: absolute;
859
+ top: 3px;
860
+ left: 3px;
861
+ width: 8px;
862
+ height: 8px;
863
+ border-radius: 999px;
864
+ background: var(--accent);
865
+ }
866
+ .option-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
867
+ .option-title { font-size: 13.5px; font-weight: 600; }
868
+ .option-desc { font-size: 12.5px; color: var(--muted); line-height: 1.45; }
869
+ .option-extra { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
870
+ .option-extra .field-error { margin-top: 4px; }
871
+
732
872
  /* ---------- toasts ---------- */
733
873
  .toast-container {
734
874
  position: fixed;
@@ -1061,6 +1201,10 @@ const clientJs = `
1061
1201
  var connectDomainInput = document.getElementById("connect-domain");
1062
1202
  var hubLocalhostRow = document.getElementById("hub-localhost-row");
1063
1203
  var hubLocalhostSwitch = document.getElementById("hub-localhost");
1204
+ var hubPanelRow = document.getElementById("hub-panel-row");
1205
+ var hubPanelSwitch = document.getElementById("hub-panel");
1206
+ var hubPanelSub = document.getElementById("hub-panel-sub");
1207
+ var hubPanelUrl = "";
1064
1208
  var connectSubmitBtn = document.getElementById("connect-submit");
1065
1209
  var connectErrorEl = document.getElementById("connect-error");
1066
1210
  var remotesEmptyEl = document.getElementById("remotes-empty");
@@ -1076,6 +1220,7 @@ const clientJs = `
1076
1220
  var startupBusy = false;
1077
1221
  var cfInstallBusy = false;
1078
1222
  var cloudflaredInstalled = false;
1223
+ var cloudflareLoggedIn = false;
1079
1224
  var proxyStopped = false;
1080
1225
  var pollTimer = null;
1081
1226
  var remotesPollTimer = null;
@@ -1293,6 +1438,7 @@ const clientJs = `
1293
1438
 
1294
1439
  var cf = sys.cloudflared || { installed: false };
1295
1440
  cloudflaredInstalled = !!cf.installed;
1441
+ cloudflareLoggedIn = !!cf.loggedIn;
1296
1442
  if (cf.installed) {
1297
1443
  cfValueEl.textContent = "Installed";
1298
1444
  cfValueEl.className = "tile-value ok";
@@ -1374,11 +1520,13 @@ const clientJs = `
1374
1520
  revokeBtn.className = "btn btn-sm btn-danger";
1375
1521
  revokeBtn.textContent = "Revoke";
1376
1522
  revokeBtn.addEventListener("click", function () {
1377
- if (!window.confirm("Revoke " + peer.name + "?")) return;
1378
- revokeBtn.disabled = true;
1379
- apiFetch("/api/peers/" + encodeURIComponent(peer.id), { method: "DELETE" })
1380
- .then(function () { showToast("Revoked " + peer.name, "success"); return loadHub(); })
1381
- .catch(function (err) { apiError(err, "Couldn't revoke peer"); revokeBtn.disabled = false; });
1523
+ confirmDialog({ title: "Revoke " + peer.name + "?", message: "This peer will lose access immediately.", confirmLabel: "Revoke", danger: true }).then(function (ok) {
1524
+ if (!ok) return;
1525
+ revokeBtn.disabled = true;
1526
+ apiFetch("/api/peers/" + encodeURIComponent(peer.id), { method: "DELETE" })
1527
+ .then(function () { showToast("Revoked " + peer.name, "success"); return loadHub(); })
1528
+ .catch(function (err) { apiError(err, "Couldn't revoke peer"); revokeBtn.disabled = false; });
1529
+ });
1382
1530
  });
1383
1531
  controls.appendChild(revokeBtn);
1384
1532
  row.appendChild(controls);
@@ -1509,6 +1657,9 @@ const clientJs = `
1509
1657
 
1510
1658
  hubLocalhostRow.hidden = !data.config;
1511
1659
  setSwitch(hubLocalhostSwitch, data.localhost !== false);
1660
+ hubPanelUrl = status === "up" && hub.url ? hub.url : "";
1661
+ hubPanelRow.hidden = !data.config;
1662
+ renderHubPanel(data.panel === true);
1512
1663
 
1513
1664
  hubErrorRow.hidden = !(status === "error" && hub.error);
1514
1665
  if (status === "error" && hub.error) hubErrorRow.textContent = hub.error;
@@ -1558,13 +1709,15 @@ const clientJs = `
1558
1709
  });
1559
1710
 
1560
1711
  hubStopBtn.addEventListener("click", function () {
1561
- if (!window.confirm("Stop remote access? Connected peers will be disconnected.")) return;
1562
- hubStopBtn.disabled = true;
1563
- hubStopBtn.classList.add("busy");
1564
- apiFetch("/api/hub", { method: "POST", body: JSON.stringify({ mode: "off" }) })
1565
- .then(function () { showToast("Remote access stopped", "success"); return loadHub(); })
1566
- .catch(function (err) { apiError(err, "Couldn't stop remote access"); })
1567
- .then(function () { hubStopBtn.disabled = false; hubStopBtn.classList.remove("busy"); });
1712
+ confirmDialog({ title: "Stop remote access?", message: "Connected peers will be disconnected.", confirmLabel: "Stop remote access", danger: true }).then(function (ok) {
1713
+ if (!ok) return;
1714
+ hubStopBtn.disabled = true;
1715
+ hubStopBtn.classList.add("busy");
1716
+ apiFetch("/api/hub", { method: "POST", body: JSON.stringify({ mode: "off" }) })
1717
+ .then(function () { showToast("Remote access stopped", "success"); return loadHub(); })
1718
+ .catch(function (err) { apiError(err, "Couldn't stop remote access"); })
1719
+ .then(function () { hubStopBtn.disabled = false; hubStopBtn.classList.remove("busy"); });
1720
+ });
1568
1721
  });
1569
1722
 
1570
1723
  // ---------- remote access: connected machines (receiver) ----------
@@ -1626,11 +1779,12 @@ const clientJs = `
1626
1779
  urlEditBtn.className = "btn btn-sm btn-ghost";
1627
1780
  urlEditBtn.textContent = "Update URL";
1628
1781
  urlEditBtn.addEventListener("click", function () {
1629
- var next = window.prompt("New URL for " + remote.name, remote.url);
1630
- if (!next || !next.trim() || next.trim() === remote.url) return;
1631
- apiFetch("/api/remotes/" + encodeURIComponent(remote.name), { method: "PUT", body: JSON.stringify({ url: next.trim() }) })
1632
- .then(function () { showToast("Updated URL for " + remote.name, "success"); return loadRemotes(); })
1633
- .catch(function (err) { apiError(err, "Couldn't update URL"); });
1782
+ promptDialog({ title: "Update URL", message: "New URL for " + remote.name, label: "URL", value: remote.url, confirmLabel: "Save" }).then(function (next) {
1783
+ if (!next || !next.trim() || next.trim() === remote.url) return;
1784
+ apiFetch("/api/remotes/" + encodeURIComponent(remote.name), { method: "PUT", body: JSON.stringify({ url: next.trim() }) })
1785
+ .then(function () { showToast("Updated URL for " + remote.name, "success"); return loadRemotes(); })
1786
+ .catch(function (err) { apiError(err, "Couldn't update URL"); });
1787
+ });
1634
1788
  });
1635
1789
  urlRow.appendChild(urlEditBtn);
1636
1790
  item.appendChild(urlRow);
@@ -1656,11 +1810,13 @@ const clientJs = `
1656
1810
  disconnectBtn.className = "btn btn-sm btn-danger";
1657
1811
  disconnectBtn.textContent = "Disconnect";
1658
1812
  disconnectBtn.addEventListener("click", function () {
1659
- if (!window.confirm("Disconnect from " + remote.name + "? This removes its hosts too.")) return;
1660
- disconnectBtn.disabled = true;
1661
- apiFetch("/api/remotes/" + encodeURIComponent(remote.name), { method: "DELETE" })
1662
- .then(function () { showToast("Disconnected " + remote.name, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
1663
- .catch(function (err) { apiError(err, "Couldn't disconnect"); disconnectBtn.disabled = false; });
1813
+ confirmDialog({ title: "Disconnect from " + remote.name + "?", message: "This removes its hosts too.", confirmLabel: "Disconnect", danger: true }).then(function (ok) {
1814
+ if (!ok) return;
1815
+ disconnectBtn.disabled = true;
1816
+ apiFetch("/api/remotes/" + encodeURIComponent(remote.name), { method: "DELETE" })
1817
+ .then(function () { showToast("Disconnected " + remote.name, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
1818
+ .catch(function (err) { apiError(err, "Couldn't disconnect"); disconnectBtn.disabled = false; });
1819
+ });
1664
1820
  });
1665
1821
  actions.appendChild(disconnectBtn);
1666
1822
  item.appendChild(actions);
@@ -1711,11 +1867,13 @@ const clientJs = `
1711
1867
  delBtn.className = "btn btn-sm btn-danger";
1712
1868
  delBtn.textContent = "Delete";
1713
1869
  delBtn.addEventListener("click", function () {
1714
- if (!window.confirm("Delete " + h.host + " on " + remote.name + "?")) return;
1715
- delBtn.disabled = true;
1716
- apiFetch("/api/remotes/" + encodeURIComponent(remote.name) + "/hosts/" + encodeURIComponent(h.host), { method: "DELETE" })
1717
- .then(function () { showToast("Deleted " + h.host, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
1718
- .catch(function (err) { apiError(err, "Couldn't delete host"); delBtn.disabled = false; });
1870
+ confirmDialog({ title: "Delete " + h.host + " on " + remote.name + "?", message: "This can't be undone.", confirmLabel: "Delete", danger: true }).then(function (ok) {
1871
+ if (!ok) return;
1872
+ delBtn.disabled = true;
1873
+ apiFetch("/api/remotes/" + encodeURIComponent(remote.name) + "/hosts/" + encodeURIComponent(h.host), { method: "DELETE" })
1874
+ .then(function () { showToast("Deleted " + h.host, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
1875
+ .catch(function (err) { apiError(err, "Couldn't delete host"); delBtn.disabled = false; });
1876
+ });
1719
1877
  });
1720
1878
  hostRow.appendChild(delBtn);
1721
1879
  }
@@ -1874,6 +2032,29 @@ const clientJs = `
1874
2032
  return row;
1875
2033
  }
1876
2034
 
2035
+ function renderHubPanel(on) {
2036
+ setSwitch(hubPanelSwitch, on);
2037
+ // Turning it on needs a password; turning it off never does.
2038
+ hubPanelSwitch.disabled = !on && !UI_AUTH;
2039
+ if (!UI_AUTH && !on) hubPanelSub.textContent = "Set a dashboard password in Settings first. Visitors sign in with it.";
2040
+ else if (on) hubPanelSub.textContent = "Open " + (hubPanelUrl || "the public URL") + " in any browser and sign in with the dashboard password.";
2041
+ else hubPanelSub.textContent = "Open this dashboard from any browser at the public URL, after signing in with the dashboard password.";
2042
+ }
2043
+
2044
+ hubPanelSwitch.addEventListener("click", function () {
2045
+ var next = hubPanelSwitch.getAttribute("aria-checked") !== "true";
2046
+ hubPanelSwitch.disabled = true;
2047
+ apiFetch("/api/hub/panel", { method: "PUT", body: JSON.stringify({ enabled: next }) })
2048
+ .then(function () {
2049
+ renderHubPanel(next);
2050
+ showToast(next ? "The dashboard is shared at the public URL" : "The dashboard is no longer shared", "success");
2051
+ })
2052
+ .catch(function (err) {
2053
+ apiError(err, "Couldn't change dashboard sharing");
2054
+ renderHubPanel(!next);
2055
+ });
2056
+ });
2057
+
1877
2058
  hubLocalhostSwitch.addEventListener("click", function () {
1878
2059
  var next = hubLocalhostSwitch.getAttribute("aria-checked") !== "true";
1879
2060
  hubLocalhostSwitch.disabled = true;
@@ -1931,29 +2112,31 @@ const clientJs = `
1931
2112
 
1932
2113
  stopProxyBtn.addEventListener("click", function () {
1933
2114
  if (proxyStopped) return;
1934
- if (!window.confirm("Stop the locadot proxy? Run \\"locadot start\\" to bring it back up.")) return;
1935
- stopProxyBtn.disabled = true;
1936
- stopProxyBtn.classList.add("busy");
1937
- apiFetch("/api/proxy/stop", { method: "POST" })
1938
- .then(function () {
1939
- proxyStopped = true;
1940
- clear(stoppedBanner);
1941
- stoppedBanner.appendChild(document.createTextNode("Proxy stopped. Run "));
1942
- var code = document.createElement("code");
1943
- code.className = "mono";
1944
- code.textContent = "locadot start";
1945
- stoppedBanner.appendChild(code);
1946
- stoppedBanner.appendChild(document.createTextNode(" to bring it back."));
1947
- stoppedBanner.hidden = false;
1948
- if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
1949
- if (remotesPollTimer) { clearInterval(remotesPollTimer); remotesPollTimer = null; }
1950
- showToast("Proxy stopped", "warn");
1951
- })
1952
- .catch(function (err) {
1953
- apiError(err, "Couldn't stop the proxy");
1954
- stopProxyBtn.disabled = false;
1955
- stopProxyBtn.classList.remove("busy");
1956
- });
2115
+ confirmDialog({ title: "Stop the locadot proxy?", message: "Run \\"locadot start\\" to bring it back up.", confirmLabel: "Stop proxy", danger: true }).then(function (ok) {
2116
+ if (!ok) return;
2117
+ stopProxyBtn.disabled = true;
2118
+ stopProxyBtn.classList.add("busy");
2119
+ apiFetch("/api/proxy/stop", { method: "POST" })
2120
+ .then(function () {
2121
+ proxyStopped = true;
2122
+ clear(stoppedBanner);
2123
+ stoppedBanner.appendChild(document.createTextNode("Proxy stopped. Run "));
2124
+ var code = document.createElement("code");
2125
+ code.className = "mono";
2126
+ code.textContent = "locadot start";
2127
+ stoppedBanner.appendChild(code);
2128
+ stoppedBanner.appendChild(document.createTextNode(" to bring it back."));
2129
+ stoppedBanner.hidden = false;
2130
+ if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
2131
+ if (remotesPollTimer) { clearInterval(remotesPollTimer); remotesPollTimer = null; }
2132
+ showToast("Proxy stopped", "warn");
2133
+ })
2134
+ .catch(function (err) {
2135
+ apiError(err, "Couldn't stop the proxy");
2136
+ stopProxyBtn.disabled = false;
2137
+ stopProxyBtn.classList.remove("busy");
2138
+ });
2139
+ });
1957
2140
  });
1958
2141
 
1959
2142
  cfInstallBtn.addEventListener("click", function () {
@@ -2102,12 +2285,36 @@ const clientJs = `
2102
2285
  link.textContent = t.url.replace("https://", "");
2103
2286
  link.title = t.url;
2104
2287
  statusWrap.appendChild(link);
2288
+ if (t.mode === "custom") {
2289
+ var modeBadge = document.createElement("span");
2290
+ modeBadge.className = "badge badge-accent";
2291
+ modeBadge.textContent = "custom";
2292
+ statusWrap.appendChild(modeBadge);
2293
+ }
2105
2294
  statusWrap.appendChild(makeCopyButton(function () { return t.url; }));
2106
2295
  } else if (t.status === "starting") {
2107
2296
  var spin = document.createElement("span");
2108
2297
  spin.className = "spinner";
2109
2298
  statusWrap.appendChild(spin);
2110
2299
  statusWrap.appendChild(document.createTextNode("Starting\\u2026"));
2300
+ } else if (t.status === "login") {
2301
+ var loginDot = document.createElement("span");
2302
+ loginDot.className = "dot warn";
2303
+ statusWrap.appendChild(loginDot);
2304
+ var loginText = document.createElement("span");
2305
+ loginText.className = "tunnel-login";
2306
+ var loginLabel = document.createElement("span");
2307
+ loginLabel.textContent = "Waiting for Cloudflare login";
2308
+ loginText.appendChild(loginLabel);
2309
+ if (t.loginUrl) {
2310
+ var loginLink = document.createElement("a");
2311
+ loginLink.href = t.loginUrl;
2312
+ loginLink.target = "_blank";
2313
+ loginLink.rel = "noopener noreferrer";
2314
+ loginLink.textContent = "Log in to Cloudflare \u2197";
2315
+ loginText.appendChild(loginLink);
2316
+ }
2317
+ statusWrap.appendChild(loginText);
2111
2318
  } else if (t.status === "error") {
2112
2319
  var errDot = document.createElement("span");
2113
2320
  errDot.className = "dot down";
@@ -2129,35 +2336,392 @@ const clientJs = `
2129
2336
 
2130
2337
  function shareButton(row) {
2131
2338
  var t = tunnelInfo(row);
2132
- var isOn = t.enabled || t.status === "up" || t.status === "starting";
2339
+ var isOn = t.enabled || t.status === "up" || t.status === "starting" || t.status === "login";
2133
2340
  var isRetry = !isOn && t.status === "error";
2134
2341
  var shareBtn = document.createElement("button");
2135
2342
  shareBtn.type = "button";
2136
2343
  shareBtn.className = "btn btn-sm" + (isOn ? " btn-danger" : "");
2137
2344
  shareBtn.textContent = isOn ? "Unshare" : (isRetry ? "Retry" : "Share");
2138
- shareBtn.title = isOn ? "Stop the public tunnel" : (isRetry ? "Retry starting the tunnel" : "Share on a public trycloudflare.com URL");
2345
+ shareBtn.title = isOn ? "Stop the public tunnel" : "Share on a public URL";
2139
2346
  if (!cloudflaredInstalled && !isOn) {
2140
2347
  shareBtn.disabled = true;
2141
2348
  shareBtn.title = "Install cloudflared first";
2142
2349
  }
2143
2350
  shareBtn.addEventListener("click", function () {
2144
- if (!isOn && !window.confirm("This will make " + row.host + " reachable from the internet by anyone with the link. Continue?")) return;
2145
- shareBtn.disabled = true;
2146
- shareBtn.classList.add("busy");
2147
- apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "PUT", body: JSON.stringify({ tunnel: !isOn }) })
2148
- .then(function () {
2149
- showToast(isOn ? "Stopping tunnel for " + row.host : "Starting tunnel for " + row.host, "success");
2150
- return loadHosts();
2151
- })
2152
- .catch(function (err) {
2153
- apiError(err, "Couldn't change sharing for " + row.host);
2154
- shareBtn.disabled = false;
2155
- shareBtn.classList.remove("busy");
2351
+ if (isOn) {
2352
+ confirmDialog({ title: "Stop sharing " + row.host + "?", message: "The public URL for " + row.host + " stops working.", confirmLabel: "Stop sharing", danger: true }).then(function (ok) {
2353
+ if (!ok) return;
2354
+ shareBtn.disabled = true;
2355
+ shareBtn.classList.add("busy");
2356
+ apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "PUT", body: JSON.stringify({ tunnel: false }) })
2357
+ .then(function () {
2358
+ showToast("Stopping tunnel for " + row.host, "success");
2359
+ return loadHosts();
2360
+ })
2361
+ .catch(function (err) {
2362
+ apiError(err, "Couldn't change sharing for " + row.host);
2363
+ shareBtn.disabled = false;
2364
+ shareBtn.classList.remove("busy");
2365
+ });
2156
2366
  });
2367
+ } else {
2368
+ openShareDialog(row);
2369
+ }
2157
2370
  });
2158
2371
  return shareBtn;
2159
2372
  }
2160
2373
 
2374
+ // ---------- share dialog ----------
2375
+
2376
+ var shareDialogEl = document.getElementById("share-dialog");
2377
+ var shareHostEl = document.getElementById("share-host");
2378
+ var shareCloseBtn = document.getElementById("share-close");
2379
+ var shareCancelBtn = document.getElementById("share-cancel");
2380
+ var shareSubmitBtn = document.getElementById("share-submit");
2381
+ var shareOptionRandom = document.getElementById("share-option-random");
2382
+ var shareOptionCustom = document.getElementById("share-option-custom");
2383
+ var shareCustomExtra = document.getElementById("share-custom-extra");
2384
+ var shareDomainInput = document.getElementById("share-domain-input");
2385
+ var shareDomainError = document.getElementById("share-domain-error");
2386
+ var shareDomainHint = document.getElementById("share-domain-hint");
2387
+ var shareErrorEl = document.getElementById("share-error");
2388
+ var shareRow = null;
2389
+ var shareMode = "random";
2390
+ var HOSTNAME_RE = /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,}$/;
2391
+
2392
+ function normalizeHostname(value) {
2393
+ return String(value || "").trim().toLowerCase().replace(/^https?:\\/\\//, "").replace(/\\/$/, "");
2394
+ }
2395
+
2396
+ function validateHostname(raw) {
2397
+ var value = normalizeHostname(raw);
2398
+ if (!value) return "Enter a hostname.";
2399
+ if (value.slice(-10) === ".localhost") return "Hostname can't be a .localhost address.";
2400
+ if (!HOSTNAME_RE.test(value)) return "Enter a valid hostname, e.g. app.example.com.";
2401
+ return null;
2402
+ }
2403
+
2404
+ function setShareMode(mode) {
2405
+ shareMode = mode;
2406
+ shareOptionRandom.setAttribute("aria-checked", String(mode === "random"));
2407
+ shareOptionCustom.setAttribute("aria-checked", String(mode === "custom"));
2408
+ shareCustomExtra.hidden = mode !== "custom";
2409
+ shareDomainHint.textContent = cloudflareLoggedIn
2410
+ ? "The DNS record is created or updated for you."
2411
+ : "You'll be asked to log in to Cloudflare once; the login link appears on the row.";
2412
+ shareDomainError.hidden = true;
2413
+ shareDomainError.textContent = "";
2414
+ shareErrorEl.hidden = true;
2415
+ shareErrorEl.textContent = "";
2416
+ if (mode === "custom") setTimeout(function () { shareDomainInput.focus(); }, 0);
2417
+ }
2418
+
2419
+ function closeShareDialog() {
2420
+ shareRow = null;
2421
+ shareDialogEl.close();
2422
+ }
2423
+
2424
+ function openShareDialog(row) {
2425
+ shareRow = row;
2426
+ shareHostEl.textContent = row.host;
2427
+ shareErrorEl.hidden = true;
2428
+ shareErrorEl.textContent = "";
2429
+ shareDomainError.hidden = true;
2430
+ shareDomainError.textContent = "";
2431
+ var hasDomain = typeof row.tunnelDomain === "string" && !!row.tunnelDomain;
2432
+ shareDomainInput.value = hasDomain ? row.tunnelDomain : "";
2433
+ setShareMode(hasDomain ? "custom" : "random");
2434
+ shareSubmitBtn.disabled = false;
2435
+ shareSubmitBtn.classList.remove("busy");
2436
+ shareCancelBtn.disabled = false;
2437
+ shareDialogEl.showModal();
2438
+ }
2439
+
2440
+ shareOptionRandom.addEventListener("click", function () { setShareMode("random"); });
2441
+ shareOptionCustom.addEventListener("click", function () { setShareMode("custom"); });
2442
+ shareOptionRandom.addEventListener("keydown", function (e) {
2443
+ if (e.key === "ArrowDown" || e.key === "ArrowRight") { e.preventDefault(); setShareMode("custom"); shareOptionCustom.focus(); }
2444
+ });
2445
+ shareOptionCustom.addEventListener("keydown", function (e) {
2446
+ if (e.key === "ArrowUp" || e.key === "ArrowLeft") { e.preventDefault(); setShareMode("random"); shareOptionRandom.focus(); }
2447
+ });
2448
+ shareCancelBtn.addEventListener("click", closeShareDialog);
2449
+ shareCloseBtn.addEventListener("click", closeShareDialog);
2450
+ shareDialogEl.addEventListener("click", function (e) {
2451
+ if (e.target === shareDialogEl) closeShareDialog();
2452
+ });
2453
+ shareDomainInput.addEventListener("keydown", function (e) {
2454
+ if (e.key === "Enter") { e.preventDefault(); shareSubmitBtn.click(); }
2455
+ });
2456
+
2457
+ shareSubmitBtn.addEventListener("click", function () {
2458
+ if (!shareRow) return;
2459
+ var row = shareRow;
2460
+ var body = { tunnel: true };
2461
+ if (shareMode === "custom") {
2462
+ var err = validateHostname(shareDomainInput.value);
2463
+ if (err) {
2464
+ shareDomainError.textContent = err;
2465
+ shareDomainError.hidden = false;
2466
+ return;
2467
+ }
2468
+ body.domain = normalizeHostname(shareDomainInput.value);
2469
+ }
2470
+ shareSubmitBtn.disabled = true;
2471
+ shareSubmitBtn.classList.add("busy");
2472
+ shareCancelBtn.disabled = true;
2473
+ apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "PUT", body: JSON.stringify(body) })
2474
+ .then(function () {
2475
+ showToast("Starting tunnel for " + row.host, "success");
2476
+ closeShareDialog();
2477
+ return loadHosts();
2478
+ })
2479
+ .catch(function (err2) {
2480
+ shareErrorEl.textContent = (err2 && err2.message) ? err2.message : String(err2);
2481
+ shareErrorEl.hidden = false;
2482
+ })
2483
+ .then(function () {
2484
+ shareSubmitBtn.disabled = false;
2485
+ shareSubmitBtn.classList.remove("busy");
2486
+ shareCancelBtn.disabled = false;
2487
+ });
2488
+ });
2489
+
2490
+ var allowDialog = document.getElementById("allow-dialog");
2491
+ var allowHostEl = document.getElementById("allow-host");
2492
+ var allowWarnEl = document.getElementById("allow-warn");
2493
+ var allowListEl = document.getElementById("allow-list");
2494
+ var allowInput = document.getElementById("allow-input");
2495
+ var allowAddBtn = document.getElementById("allow-add");
2496
+ var allowErrorEl = document.getElementById("allow-error");
2497
+ var allowCancelBtn = document.getElementById("allow-cancel");
2498
+ var allowSaveBtn = document.getElementById("allow-save");
2499
+ var allowCloseBtn = document.getElementById("allow-close");
2500
+ var allowOriginal = [];
2501
+ var allowCurrent = [];
2502
+ var allowRow = null;
2503
+
2504
+ function allowListsEqual(a, b) {
2505
+ if (a.length !== b.length) return false;
2506
+ for (var i = 0; i < a.length; i++) {
2507
+ if (a[i] !== b[i]) return false;
2508
+ }
2509
+ return true;
2510
+ }
2511
+
2512
+ function updateAllowSave() {
2513
+ allowSaveBtn.disabled = allowListsEqual(allowCurrent, allowOriginal);
2514
+ }
2515
+
2516
+ function renderAllowList() {
2517
+ clear(allowListEl);
2518
+ if (!allowCurrent.length) {
2519
+ var empty = document.createElement("div");
2520
+ empty.className = "dim allow-empty";
2521
+ empty.textContent = "No internal addresses. Shared visitors reach public hosts only.";
2522
+ allowListEl.appendChild(empty);
2523
+ return;
2524
+ }
2525
+ allowCurrent.forEach(function (entry) {
2526
+ var entryRow = document.createElement("div");
2527
+ entryRow.className = "allow-row";
2528
+ var text = document.createElement("span");
2529
+ text.className = "mono";
2530
+ text.textContent = entry;
2531
+ entryRow.appendChild(text);
2532
+ var removeBtn = document.createElement("button");
2533
+ removeBtn.type = "button";
2534
+ removeBtn.className = "allow-remove";
2535
+ removeBtn.textContent = "\\u00d7";
2536
+ removeBtn.setAttribute("aria-label", "Remove " + entry);
2537
+ removeBtn.addEventListener("click", function () {
2538
+ allowCurrent = allowCurrent.filter(function (item) { return item !== entry; });
2539
+ renderAllowList();
2540
+ updateAllowSave();
2541
+ });
2542
+ entryRow.appendChild(removeBtn);
2543
+ allowListEl.appendChild(entryRow);
2544
+ });
2545
+ }
2546
+
2547
+ function allowAddEntry() {
2548
+ var value = allowInput.value.trim().toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\\/\\//, "").replace(/\\/$/, "");
2549
+ allowErrorEl.hidden = true;
2550
+ allowErrorEl.textContent = "";
2551
+ if (!value || /\\s/.test(value)) {
2552
+ allowErrorEl.textContent = "Enter a host or host:port, e.g. localhost:3000.";
2553
+ allowErrorEl.hidden = false;
2554
+ return;
2555
+ }
2556
+ if (allowCurrent.indexOf(value) !== -1) {
2557
+ allowErrorEl.textContent = value + " is already in the list.";
2558
+ allowErrorEl.hidden = false;
2559
+ return;
2560
+ }
2561
+ allowCurrent.push(value);
2562
+ allowInput.value = "";
2563
+ renderAllowList();
2564
+ updateAllowSave();
2565
+ }
2566
+
2567
+ function closeAllowDialog() {
2568
+ allowDialog.close();
2569
+ }
2570
+
2571
+ function openAllowDialog(row) {
2572
+ allowRow = row;
2573
+ allowOriginal = (row.allow || []).slice();
2574
+ allowCurrent = allowOriginal.slice();
2575
+ allowHostEl.textContent = row.host;
2576
+ allowWarnEl.hidden = !!row.cors;
2577
+ allowErrorEl.hidden = true;
2578
+ allowErrorEl.textContent = "";
2579
+ allowInput.value = "";
2580
+ renderAllowList();
2581
+ allowSaveBtn.disabled = true;
2582
+ allowSaveBtn.classList.remove("busy");
2583
+ allowCancelBtn.disabled = false;
2584
+ allowDialog.showModal();
2585
+ setTimeout(function () { allowInput.focus(); }, 0);
2586
+ }
2587
+
2588
+ allowAddBtn.addEventListener("click", allowAddEntry);
2589
+ allowInput.addEventListener("keydown", function (e) {
2590
+ if (e.key === "Enter") { e.preventDefault(); allowAddEntry(); }
2591
+ });
2592
+ allowCancelBtn.addEventListener("click", closeAllowDialog);
2593
+ allowCloseBtn.addEventListener("click", closeAllowDialog);
2594
+ allowDialog.addEventListener("click", function (e) {
2595
+ if (e.target === allowDialog) closeAllowDialog();
2596
+ });
2597
+
2598
+ allowSaveBtn.addEventListener("click", function () {
2599
+ if (!allowRow) return;
2600
+ var host = allowRow.host;
2601
+ allowSaveBtn.disabled = true;
2602
+ allowSaveBtn.classList.add("busy");
2603
+ allowCancelBtn.disabled = true;
2604
+ apiFetch("/api/hosts/" + encodeURIComponent(host), { method: "PUT", body: JSON.stringify({ allow: allowCurrent }) })
2605
+ .then(function () {
2606
+ showToast("Saved internal access for " + host, "success");
2607
+ closeAllowDialog();
2608
+ return loadHosts();
2609
+ })
2610
+ .catch(function (err) {
2611
+ allowErrorEl.textContent = err && err.message ? err.message : String(err);
2612
+ allowErrorEl.hidden = false;
2613
+ allowSaveBtn.disabled = false;
2614
+ allowSaveBtn.classList.remove("busy");
2615
+ allowCancelBtn.disabled = false;
2616
+ });
2617
+ });
2618
+
2619
+ // ---------- confirm / prompt dialogs ----------
2620
+
2621
+ var confirmDialogEl = document.getElementById("confirm-dialog");
2622
+ var confirmTitleEl = document.getElementById("confirm-title");
2623
+ var confirmMessageEl = document.getElementById("confirm-message");
2624
+ var confirmCancelBtn = document.getElementById("confirm-cancel");
2625
+ var confirmOkBtn = document.getElementById("confirm-ok");
2626
+ var confirmCloseBtn = document.getElementById("confirm-close");
2627
+ var confirmResolve = null;
2628
+
2629
+ function closeConfirmDialog(result) {
2630
+ var resolve = confirmResolve;
2631
+ confirmResolve = null;
2632
+ confirmDialogEl.close();
2633
+ if (resolve) resolve(result);
2634
+ }
2635
+
2636
+ function confirmDialog(opts) {
2637
+ opts = opts || {};
2638
+ return new Promise(function (resolve) {
2639
+ confirmResolve = resolve;
2640
+ confirmTitleEl.textContent = opts.title || "Are you sure?";
2641
+ confirmMessageEl.textContent = opts.message || "";
2642
+ confirmOkBtn.textContent = opts.confirmLabel || "OK";
2643
+ confirmOkBtn.className = "btn " + (opts.danger ? "btn-danger" : "btn-primary");
2644
+ confirmDialogEl.showModal();
2645
+ setTimeout(function () { confirmOkBtn.focus(); }, 0);
2646
+ });
2647
+ }
2648
+
2649
+ confirmCancelBtn.addEventListener("click", function () { closeConfirmDialog(false); });
2650
+ confirmCloseBtn.addEventListener("click", function () { closeConfirmDialog(false); });
2651
+ confirmOkBtn.addEventListener("click", function () { closeConfirmDialog(true); });
2652
+ confirmDialogEl.addEventListener("click", function (e) {
2653
+ if (e.target === confirmDialogEl) closeConfirmDialog(false);
2654
+ });
2655
+ confirmDialogEl.addEventListener("close", function () {
2656
+ if (confirmResolve) { var resolve = confirmResolve; confirmResolve = null; resolve(false); }
2657
+ });
2658
+
2659
+ var promptDialogEl = document.getElementById("prompt-dialog");
2660
+ var promptTitleEl = document.getElementById("prompt-title");
2661
+ var promptMessageEl = document.getElementById("prompt-message");
2662
+ var promptLabelEl = document.getElementById("prompt-label");
2663
+ var promptInput = document.getElementById("prompt-input");
2664
+ var promptErrorEl = document.getElementById("prompt-error");
2665
+ var promptCancelBtn = document.getElementById("prompt-cancel");
2666
+ var promptConfirmBtn = document.getElementById("prompt-confirm");
2667
+ var promptCloseBtn = document.getElementById("prompt-close");
2668
+ var promptResolve = null;
2669
+ var promptValidate = null;
2670
+
2671
+ function closePromptDialog(result) {
2672
+ var resolve = promptResolve;
2673
+ promptResolve = null;
2674
+ promptValidate = null;
2675
+ promptDialogEl.close();
2676
+ if (resolve) resolve(result);
2677
+ }
2678
+
2679
+ function promptDialog(opts) {
2680
+ opts = opts || {};
2681
+ return new Promise(function (resolve) {
2682
+ promptResolve = resolve;
2683
+ promptValidate = typeof opts.validate === "function" ? opts.validate : null;
2684
+ promptTitleEl.textContent = opts.title || "";
2685
+ promptMessageEl.textContent = opts.message || "";
2686
+ promptMessageEl.hidden = !opts.message;
2687
+ promptLabelEl.textContent = opts.label || "";
2688
+ promptLabelEl.hidden = !opts.label;
2689
+ promptInput.placeholder = opts.placeholder || "";
2690
+ promptInput.value = opts.value || "";
2691
+ promptConfirmBtn.textContent = opts.confirmLabel || "OK";
2692
+ promptErrorEl.hidden = true;
2693
+ promptErrorEl.textContent = "";
2694
+ promptDialogEl.showModal();
2695
+ setTimeout(function () { promptInput.focus(); promptInput.select(); }, 0);
2696
+ });
2697
+ }
2698
+
2699
+ function submitPromptDialog() {
2700
+ var value = promptInput.value;
2701
+ if (promptValidate) {
2702
+ var err = promptValidate(value);
2703
+ if (err) {
2704
+ promptErrorEl.textContent = err;
2705
+ promptErrorEl.hidden = false;
2706
+ return;
2707
+ }
2708
+ }
2709
+ closePromptDialog(value);
2710
+ }
2711
+
2712
+ promptCancelBtn.addEventListener("click", function () { closePromptDialog(null); });
2713
+ promptCloseBtn.addEventListener("click", function () { closePromptDialog(null); });
2714
+ promptConfirmBtn.addEventListener("click", submitPromptDialog);
2715
+ promptInput.addEventListener("keydown", function (e) {
2716
+ if (e.key === "Enter") { e.preventDefault(); submitPromptDialog(); }
2717
+ });
2718
+ promptDialogEl.addEventListener("click", function (e) {
2719
+ if (e.target === promptDialogEl) closePromptDialog(null);
2720
+ });
2721
+ promptDialogEl.addEventListener("close", function () {
2722
+ if (promptResolve) { var resolve = promptResolve; promptResolve = null; resolve(null); }
2723
+ });
2724
+
2161
2725
  function labelCells(tr, labels) {
2162
2726
  for (var i = 0; i < tr.children.length; i++) tr.children[i].setAttribute("data-label", labels[i] || "");
2163
2727
  }
@@ -2198,13 +2762,14 @@ const clientJs = `
2198
2762
 
2199
2763
  var optionsTd = document.createElement("td");
2200
2764
  var flags = [];
2201
- if (row.insecure) flags.push(["insecure", "Upstream TLS certificate is not verified"]);
2202
- if (row.cors) flags.push(["cors", "Origin/Referer rewritten to the target; any origin may call this host"]);
2765
+ if (row.insecure) flags.push(["insecure", "Upstream TLS certificate is not verified", "badge-warn"]);
2766
+ if (row.cors) flags.push(["cors", "Origin/Referer rewritten to the target; any origin may call this host", "badge-cors"]);
2767
+ if (row.allow && row.allow.length) flags.push(["allow " + row.allow.length, row.allow.join(", "), "badge-accent"]);
2203
2768
  if (flags.length) {
2204
2769
  optionsTd.className = "badges";
2205
2770
  flags.forEach(function (flag) {
2206
2771
  var badge = document.createElement("span");
2207
- badge.className = "badge " + (flag[0] === "cors" ? "badge-cors" : "badge-warn");
2772
+ badge.className = "badge " + flag[2];
2208
2773
  badge.textContent = flag[0];
2209
2774
  badge.title = flag[1];
2210
2775
  optionsTd.appendChild(badge);
@@ -2235,6 +2800,20 @@ const clientJs = `
2235
2800
  var actionsWrap = document.createElement("div");
2236
2801
  actionsWrap.className = "row-actions";
2237
2802
  actionsTd.appendChild(actionsWrap);
2803
+ var accessBtn = document.createElement("button");
2804
+ accessBtn.type = "button";
2805
+ var allowCount = row.allow ? row.allow.length : 0;
2806
+ accessBtn.className = "btn btn-sm btn-icon" + (allowCount ? " is-set" : "");
2807
+ accessBtn.title = allowCount ? "Internal access: " + row.allow.join(", ") : "Internal access: let shared visitors reach addresses such as localhost:3000";
2808
+ accessBtn.setAttribute("aria-label", "Internal access for " + row.host);
2809
+ var accessSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
2810
+ accessSvg.setAttribute("viewBox", "0 0 24 24");
2811
+ accessSvg.setAttribute("aria-hidden", "true");
2812
+ var accessPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
2813
+ accessPath.setAttribute("d", "M12 3l7 3v5c0 4.5-3 8.3-7 10-4-1.7-7-5.5-7-10V6l7-3zM9.5 12l1.8 1.8L15 10");
2814
+ accessSvg.appendChild(accessPath);
2815
+ accessBtn.appendChild(accessSvg);
2816
+ accessBtn.addEventListener("click", function () { openAllowDialog(row); });
2238
2817
  var editBtn = document.createElement("button");
2239
2818
  editBtn.type = "button";
2240
2819
  editBtn.className = "btn btn-sm";
@@ -2245,20 +2824,23 @@ const clientJs = `
2245
2824
  removeBtn.className = "btn btn-sm btn-danger";
2246
2825
  removeBtn.textContent = "Remove";
2247
2826
  removeBtn.addEventListener("click", function () {
2248
- if (prefs.confirm && !window.confirm("Remove host " + row.host + "?")) return;
2249
- removeBtn.disabled = true;
2250
- apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "DELETE" })
2251
- .then(function () {
2252
- showToast("Removed " + row.host, "success");
2253
- return loadHosts();
2254
- })
2255
- .catch(function (err) {
2256
- apiError(err, "Couldn't remove " + row.host);
2257
- removeBtn.disabled = false;
2258
- });
2827
+ (prefs.confirm ? confirmDialog({ title: "Remove host " + row.host + "?", message: "This can't be undone.", confirmLabel: "Remove host", danger: true }) : Promise.resolve(true)).then(function (ok) {
2828
+ if (!ok) return;
2829
+ removeBtn.disabled = true;
2830
+ apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "DELETE" })
2831
+ .then(function () {
2832
+ showToast("Removed " + row.host, "success");
2833
+ return loadHosts();
2834
+ })
2835
+ .catch(function (err) {
2836
+ apiError(err, "Couldn't remove " + row.host);
2837
+ removeBtn.disabled = false;
2838
+ });
2839
+ });
2259
2840
  });
2260
2841
  if (!row.remote) {
2261
2842
  actionsWrap.appendChild(shareButton(row));
2843
+ actionsWrap.appendChild(accessBtn);
2262
2844
  actionsWrap.appendChild(editBtn);
2263
2845
  }
2264
2846
  actionsWrap.appendChild(removeBtn);
@@ -2424,13 +3006,15 @@ const clientJs = `
2424
3006
 
2425
3007
  logsRefreshBtn.addEventListener("click", loadLogs);
2426
3008
  logsClearBtn.addEventListener("click", function () {
2427
- if (!window.confirm("Clear logs?")) return;
2428
- apiFetch("/api/logs/clear", { method: "POST" })
2429
- .then(function () {
2430
- showToast("Logs cleared", "success");
2431
- return loadLogs();
2432
- })
2433
- .catch(function (err) { apiError(err, "Couldn't clear logs"); });
3009
+ confirmDialog({ title: "Clear logs?", message: "This can't be undone.", confirmLabel: "Clear logs", danger: true }).then(function (ok) {
3010
+ if (!ok) return;
3011
+ apiFetch("/api/logs/clear", { method: "POST" })
3012
+ .then(function () {
3013
+ showToast("Logs cleared", "success");
3014
+ return loadLogs();
3015
+ })
3016
+ .catch(function (err) { apiError(err, "Couldn't clear logs"); });
3017
+ });
2434
3018
  });
2435
3019
 
2436
3020
  cliAddCopy.addEventListener("click", function () { copyText(cliAddPre.textContent, cliAddCopy); });
@@ -2856,7 +3440,8 @@ const clientJs = `
2856
3440
  });
2857
3441
 
2858
3442
  restartBtn.addEventListener("click", function () {
2859
- if (!window.confirm("Restart the proxy? Hosts are unavailable for a few seconds.")) return;
3443
+ confirmDialog({ title: "Restart the proxy?", message: "Hosts are unavailable for a few seconds.", confirmLabel: "Restart", danger: true }).then(function (ok) {
3444
+ if (!ok) return;
2860
3445
  var s = settingsLoaded;
2861
3446
  var nextPort = s ? ((s.env && s.env.httpPort) ? s.httpPort : ((s.saved && s.saved.httpPort) || s.httpPort)) : null;
2862
3447
  restartBtn.disabled = true;
@@ -2889,6 +3474,7 @@ const clientJs = `
2889
3474
  restartBtn.disabled = false;
2890
3475
  restartBtn.classList.remove("busy");
2891
3476
  });
3477
+ });
2892
3478
  });
2893
3479
 
2894
3480
  document.getElementById("set-home-copy").addEventListener("click", function (e) { copyText(setHome.textContent, e.currentTarget); });
@@ -2962,6 +3548,7 @@ const clientJs = `
2962
3548
  uiAuthSave.textContent = enabled ? "Change password" : "Set password";
2963
3549
  uiAuthRemove.hidden = !enabled;
2964
3550
  signOutBtn.hidden = !enabled;
3551
+ renderHubPanel(enabled && hubPanelSwitch.getAttribute("aria-checked") === "true");
2965
3552
  }
2966
3553
  renderUiAuth(UI_AUTH);
2967
3554
 
@@ -3314,6 +3901,13 @@ function renderPage(nonce, token, uiAuth = false) {
3314
3901
  </div>
3315
3902
  <button type="button" id="hub-localhost" class="switch" role="switch" aria-checked="true" aria-label="Let admins open this dashboard and any port on this machine"><span class="switch-knob"></span></button>
3316
3903
  </div>
3904
+ <div id="hub-panel-row" class="toggle-row" hidden>
3905
+ <div>
3906
+ <div class="label">Share this dashboard</div>
3907
+ <div id="hub-panel-sub" class="tile-sub"></div>
3908
+ </div>
3909
+ <button type="button" id="hub-panel" class="switch" role="switch" aria-checked="false" aria-label="Share this dashboard at the public URL, behind the dashboard password"><span class="switch-knob"></span></button>
3910
+ </div>
3317
3911
  </div>
3318
3912
 
3319
3913
  <div id="hub-share-section" class="hub-section" hidden>
@@ -3611,6 +4205,104 @@ function renderPage(nonce, token, uiAuth = false) {
3611
4205
  <a href="https://github.com/avinashid/locadot" target="_blank" rel="noopener noreferrer">github.com/avinashid/locadot</a>
3612
4206
  </footer>
3613
4207
  </div>
4208
+ <dialog id="allow-dialog">
4209
+ <div class="modal-head">
4210
+ <div>
4211
+ <h3 class="modal-title">Internal access</h3>
4212
+ <p id="allow-host" class="modal-sub mono"></p>
4213
+ </div>
4214
+ <button type="button" id="allow-close" class="btn btn-ghost modal-close" aria-label="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
4215
+ </div>
4216
+ <div class="modal-body">
4217
+ <p class="modal-desc">People who open this mapping through a tunnel or remote access can only reach public hosts. Allow the internal addresses this app calls, such as its API on localhost.</p>
4218
+ <div id="allow-warn" class="modal-warn" hidden>Bypass CORS is off for this mapping. These addresses take effect once it's on, because the page reaches them through locadot's pass-through.</div>
4219
+ <div id="allow-list" class="allow-list"></div>
4220
+ <div class="allow-add-row">
4221
+ <input type="text" id="allow-input" class="input" placeholder="localhost:3000" aria-label="Address" autocomplete="off">
4222
+ <button type="button" id="allow-add" class="btn btn-sm">Add</button>
4223
+ </div>
4224
+ <p class="modal-hint">host:port, or a host alone for any port. locadot's own ports are always blocked.</p>
4225
+ <div id="allow-error" class="field-error" role="alert" hidden></div>
4226
+ </div>
4227
+ <div class="modal-foot">
4228
+ <button type="button" id="allow-cancel" class="btn">Cancel</button>
4229
+ <button type="button" id="allow-save" class="btn btn-primary" disabled>Save changes</button>
4230
+ </div>
4231
+ </dialog>
4232
+ <dialog id="confirm-dialog">
4233
+ <div class="modal-head">
4234
+ <div>
4235
+ <h3 class="modal-title" id="confirm-title"></h3>
4236
+ </div>
4237
+ <button type="button" id="confirm-close" class="btn btn-ghost modal-close" aria-label="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
4238
+ </div>
4239
+ <div class="modal-body">
4240
+ <p class="modal-desc" id="confirm-message"></p>
4241
+ </div>
4242
+ <div class="modal-foot">
4243
+ <button type="button" id="confirm-cancel" class="btn">Cancel</button>
4244
+ <button type="button" id="confirm-ok" class="btn btn-primary">OK</button>
4245
+ </div>
4246
+ </dialog>
4247
+ <dialog id="prompt-dialog">
4248
+ <div class="modal-head">
4249
+ <div>
4250
+ <h3 class="modal-title" id="prompt-title"></h3>
4251
+ </div>
4252
+ <button type="button" id="prompt-close" class="btn btn-ghost modal-close" aria-label="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
4253
+ </div>
4254
+ <div class="modal-body">
4255
+ <p class="modal-desc" id="prompt-message"></p>
4256
+ <div class="field">
4257
+ <label id="prompt-label" for="prompt-input"></label>
4258
+ <input type="text" id="prompt-input" class="input" autocomplete="off">
4259
+ </div>
4260
+ <div id="prompt-error" class="field-error" role="alert" hidden></div>
4261
+ </div>
4262
+ <div class="modal-foot">
4263
+ <button type="button" id="prompt-cancel" class="btn">Cancel</button>
4264
+ <button type="button" id="prompt-confirm" class="btn btn-primary">OK</button>
4265
+ </div>
4266
+ </dialog>
4267
+ <dialog id="share-dialog">
4268
+ <div class="modal-head">
4269
+ <div>
4270
+ <h3 class="modal-title">Share publicly</h3>
4271
+ <p id="share-host" class="modal-sub mono"></p>
4272
+ </div>
4273
+ <button type="button" id="share-close" class="btn btn-ghost modal-close" aria-label="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
4274
+ </div>
4275
+ <div class="modal-body">
4276
+ <p class="modal-desc">Anyone with the link will be able to reach this host, without signing in.</p>
4277
+ <div id="share-options" class="share-options" role="radiogroup" aria-label="Sharing method">
4278
+ <button type="button" id="share-option-random" class="option-card" role="radio" aria-checked="false">
4279
+ <span class="option-dot" aria-hidden="true"></span>
4280
+ <span class="option-body">
4281
+ <span class="option-title">Random URL</span>
4282
+ <span class="option-desc">https://&lt;random&gt;.trycloudflare.com &middot; No account needed. Changes each time sharing restarts.</span>
4283
+ </span>
4284
+ </button>
4285
+ <button type="button" id="share-option-custom" class="option-card" role="radio" aria-checked="false">
4286
+ <span class="option-dot" aria-hidden="true"></span>
4287
+ <span class="option-body">
4288
+ <span class="option-title">Custom domain</span>
4289
+ <span class="option-desc">Your own hostname on a domain in your Cloudflare account. Stable URL.</span>
4290
+ <span id="share-custom-extra" class="option-extra" hidden>
4291
+ <label for="share-domain-input" class="dim">Hostname</label>
4292
+ <input type="text" id="share-domain-input" class="input mono" placeholder="app.example.com" autocomplete="off">
4293
+ <div id="share-domain-error" class="field-error" role="alert" hidden></div>
4294
+ <p id="share-domain-hint" class="modal-hint"></p>
4295
+ </span>
4296
+ </span>
4297
+ </button>
4298
+ </div>
4299
+ <div id="share-error" class="field-error" role="alert" hidden></div>
4300
+ </div>
4301
+ <div class="modal-foot">
4302
+ <button type="button" id="share-cancel" class="btn">Cancel</button>
4303
+ <button type="button" id="share-submit" class="btn btn-primary">Start sharing</button>
4304
+ </div>
4305
+ </dialog>
3614
4306
  <div id="toast-container" class="toast-container" aria-live="polite"></div>
3615
4307
  <script nonce="${safeNonce}">${clientJs}</script>
3616
4308
  </body>