locadot 2.2.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;
@@ -1080,6 +1220,7 @@ const clientJs = `
1080
1220
  var startupBusy = false;
1081
1221
  var cfInstallBusy = false;
1082
1222
  var cloudflaredInstalled = false;
1223
+ var cloudflareLoggedIn = false;
1083
1224
  var proxyStopped = false;
1084
1225
  var pollTimer = null;
1085
1226
  var remotesPollTimer = null;
@@ -1297,6 +1438,7 @@ const clientJs = `
1297
1438
 
1298
1439
  var cf = sys.cloudflared || { installed: false };
1299
1440
  cloudflaredInstalled = !!cf.installed;
1441
+ cloudflareLoggedIn = !!cf.loggedIn;
1300
1442
  if (cf.installed) {
1301
1443
  cfValueEl.textContent = "Installed";
1302
1444
  cfValueEl.className = "tile-value ok";
@@ -1378,11 +1520,13 @@ const clientJs = `
1378
1520
  revokeBtn.className = "btn btn-sm btn-danger";
1379
1521
  revokeBtn.textContent = "Revoke";
1380
1522
  revokeBtn.addEventListener("click", function () {
1381
- if (!window.confirm("Revoke " + peer.name + "?")) return;
1382
- revokeBtn.disabled = true;
1383
- apiFetch("/api/peers/" + encodeURIComponent(peer.id), { method: "DELETE" })
1384
- .then(function () { showToast("Revoked " + peer.name, "success"); return loadHub(); })
1385
- .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
+ });
1386
1530
  });
1387
1531
  controls.appendChild(revokeBtn);
1388
1532
  row.appendChild(controls);
@@ -1565,13 +1709,15 @@ const clientJs = `
1565
1709
  });
1566
1710
 
1567
1711
  hubStopBtn.addEventListener("click", function () {
1568
- if (!window.confirm("Stop remote access? Connected peers will be disconnected.")) return;
1569
- hubStopBtn.disabled = true;
1570
- hubStopBtn.classList.add("busy");
1571
- apiFetch("/api/hub", { method: "POST", body: JSON.stringify({ mode: "off" }) })
1572
- .then(function () { showToast("Remote access stopped", "success"); return loadHub(); })
1573
- .catch(function (err) { apiError(err, "Couldn't stop remote access"); })
1574
- .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
+ });
1575
1721
  });
1576
1722
 
1577
1723
  // ---------- remote access: connected machines (receiver) ----------
@@ -1633,11 +1779,12 @@ const clientJs = `
1633
1779
  urlEditBtn.className = "btn btn-sm btn-ghost";
1634
1780
  urlEditBtn.textContent = "Update URL";
1635
1781
  urlEditBtn.addEventListener("click", function () {
1636
- var next = window.prompt("New URL for " + remote.name, remote.url);
1637
- if (!next || !next.trim() || next.trim() === remote.url) return;
1638
- apiFetch("/api/remotes/" + encodeURIComponent(remote.name), { method: "PUT", body: JSON.stringify({ url: next.trim() }) })
1639
- .then(function () { showToast("Updated URL for " + remote.name, "success"); return loadRemotes(); })
1640
- .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
+ });
1641
1788
  });
1642
1789
  urlRow.appendChild(urlEditBtn);
1643
1790
  item.appendChild(urlRow);
@@ -1663,11 +1810,13 @@ const clientJs = `
1663
1810
  disconnectBtn.className = "btn btn-sm btn-danger";
1664
1811
  disconnectBtn.textContent = "Disconnect";
1665
1812
  disconnectBtn.addEventListener("click", function () {
1666
- if (!window.confirm("Disconnect from " + remote.name + "? This removes its hosts too.")) return;
1667
- disconnectBtn.disabled = true;
1668
- apiFetch("/api/remotes/" + encodeURIComponent(remote.name), { method: "DELETE" })
1669
- .then(function () { showToast("Disconnected " + remote.name, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
1670
- .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
+ });
1671
1820
  });
1672
1821
  actions.appendChild(disconnectBtn);
1673
1822
  item.appendChild(actions);
@@ -1718,11 +1867,13 @@ const clientJs = `
1718
1867
  delBtn.className = "btn btn-sm btn-danger";
1719
1868
  delBtn.textContent = "Delete";
1720
1869
  delBtn.addEventListener("click", function () {
1721
- if (!window.confirm("Delete " + h.host + " on " + remote.name + "?")) return;
1722
- delBtn.disabled = true;
1723
- apiFetch("/api/remotes/" + encodeURIComponent(remote.name) + "/hosts/" + encodeURIComponent(h.host), { method: "DELETE" })
1724
- .then(function () { showToast("Deleted " + h.host, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
1725
- .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
+ });
1726
1877
  });
1727
1878
  hostRow.appendChild(delBtn);
1728
1879
  }
@@ -1961,29 +2112,31 @@ const clientJs = `
1961
2112
 
1962
2113
  stopProxyBtn.addEventListener("click", function () {
1963
2114
  if (proxyStopped) return;
1964
- if (!window.confirm("Stop the locadot proxy? Run \\"locadot start\\" to bring it back up.")) return;
1965
- stopProxyBtn.disabled = true;
1966
- stopProxyBtn.classList.add("busy");
1967
- apiFetch("/api/proxy/stop", { method: "POST" })
1968
- .then(function () {
1969
- proxyStopped = true;
1970
- clear(stoppedBanner);
1971
- stoppedBanner.appendChild(document.createTextNode("Proxy stopped. Run "));
1972
- var code = document.createElement("code");
1973
- code.className = "mono";
1974
- code.textContent = "locadot start";
1975
- stoppedBanner.appendChild(code);
1976
- stoppedBanner.appendChild(document.createTextNode(" to bring it back."));
1977
- stoppedBanner.hidden = false;
1978
- if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
1979
- if (remotesPollTimer) { clearInterval(remotesPollTimer); remotesPollTimer = null; }
1980
- showToast("Proxy stopped", "warn");
1981
- })
1982
- .catch(function (err) {
1983
- apiError(err, "Couldn't stop the proxy");
1984
- stopProxyBtn.disabled = false;
1985
- stopProxyBtn.classList.remove("busy");
1986
- });
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
+ });
1987
2140
  });
1988
2141
 
1989
2142
  cfInstallBtn.addEventListener("click", function () {
@@ -2132,12 +2285,36 @@ const clientJs = `
2132
2285
  link.textContent = t.url.replace("https://", "");
2133
2286
  link.title = t.url;
2134
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
+ }
2135
2294
  statusWrap.appendChild(makeCopyButton(function () { return t.url; }));
2136
2295
  } else if (t.status === "starting") {
2137
2296
  var spin = document.createElement("span");
2138
2297
  spin.className = "spinner";
2139
2298
  statusWrap.appendChild(spin);
2140
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);
2141
2318
  } else if (t.status === "error") {
2142
2319
  var errDot = document.createElement("span");
2143
2320
  errDot.className = "dot down";
@@ -2159,35 +2336,392 @@ const clientJs = `
2159
2336
 
2160
2337
  function shareButton(row) {
2161
2338
  var t = tunnelInfo(row);
2162
- var isOn = t.enabled || t.status === "up" || t.status === "starting";
2339
+ var isOn = t.enabled || t.status === "up" || t.status === "starting" || t.status === "login";
2163
2340
  var isRetry = !isOn && t.status === "error";
2164
2341
  var shareBtn = document.createElement("button");
2165
2342
  shareBtn.type = "button";
2166
2343
  shareBtn.className = "btn btn-sm" + (isOn ? " btn-danger" : "");
2167
2344
  shareBtn.textContent = isOn ? "Unshare" : (isRetry ? "Retry" : "Share");
2168
- 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";
2169
2346
  if (!cloudflaredInstalled && !isOn) {
2170
2347
  shareBtn.disabled = true;
2171
2348
  shareBtn.title = "Install cloudflared first";
2172
2349
  }
2173
2350
  shareBtn.addEventListener("click", function () {
2174
- if (!isOn && !window.confirm("This will make " + row.host + " reachable from the internet by anyone with the link. Continue?")) return;
2175
- shareBtn.disabled = true;
2176
- shareBtn.classList.add("busy");
2177
- apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "PUT", body: JSON.stringify({ tunnel: !isOn }) })
2178
- .then(function () {
2179
- showToast(isOn ? "Stopping tunnel for " + row.host : "Starting tunnel for " + row.host, "success");
2180
- return loadHosts();
2181
- })
2182
- .catch(function (err) {
2183
- apiError(err, "Couldn't change sharing for " + row.host);
2184
- shareBtn.disabled = false;
2185
- 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
+ });
2186
2366
  });
2367
+ } else {
2368
+ openShareDialog(row);
2369
+ }
2187
2370
  });
2188
2371
  return shareBtn;
2189
2372
  }
2190
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
+
2191
2725
  function labelCells(tr, labels) {
2192
2726
  for (var i = 0; i < tr.children.length; i++) tr.children[i].setAttribute("data-label", labels[i] || "");
2193
2727
  }
@@ -2228,13 +2762,14 @@ const clientJs = `
2228
2762
 
2229
2763
  var optionsTd = document.createElement("td");
2230
2764
  var flags = [];
2231
- if (row.insecure) flags.push(["insecure", "Upstream TLS certificate is not verified"]);
2232
- 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"]);
2233
2768
  if (flags.length) {
2234
2769
  optionsTd.className = "badges";
2235
2770
  flags.forEach(function (flag) {
2236
2771
  var badge = document.createElement("span");
2237
- badge.className = "badge " + (flag[0] === "cors" ? "badge-cors" : "badge-warn");
2772
+ badge.className = "badge " + flag[2];
2238
2773
  badge.textContent = flag[0];
2239
2774
  badge.title = flag[1];
2240
2775
  optionsTd.appendChild(badge);
@@ -2265,6 +2800,20 @@ const clientJs = `
2265
2800
  var actionsWrap = document.createElement("div");
2266
2801
  actionsWrap.className = "row-actions";
2267
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); });
2268
2817
  var editBtn = document.createElement("button");
2269
2818
  editBtn.type = "button";
2270
2819
  editBtn.className = "btn btn-sm";
@@ -2275,20 +2824,23 @@ const clientJs = `
2275
2824
  removeBtn.className = "btn btn-sm btn-danger";
2276
2825
  removeBtn.textContent = "Remove";
2277
2826
  removeBtn.addEventListener("click", function () {
2278
- if (prefs.confirm && !window.confirm("Remove host " + row.host + "?")) return;
2279
- removeBtn.disabled = true;
2280
- apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "DELETE" })
2281
- .then(function () {
2282
- showToast("Removed " + row.host, "success");
2283
- return loadHosts();
2284
- })
2285
- .catch(function (err) {
2286
- apiError(err, "Couldn't remove " + row.host);
2287
- removeBtn.disabled = false;
2288
- });
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
+ });
2289
2840
  });
2290
2841
  if (!row.remote) {
2291
2842
  actionsWrap.appendChild(shareButton(row));
2843
+ actionsWrap.appendChild(accessBtn);
2292
2844
  actionsWrap.appendChild(editBtn);
2293
2845
  }
2294
2846
  actionsWrap.appendChild(removeBtn);
@@ -2454,13 +3006,15 @@ const clientJs = `
2454
3006
 
2455
3007
  logsRefreshBtn.addEventListener("click", loadLogs);
2456
3008
  logsClearBtn.addEventListener("click", function () {
2457
- if (!window.confirm("Clear logs?")) return;
2458
- apiFetch("/api/logs/clear", { method: "POST" })
2459
- .then(function () {
2460
- showToast("Logs cleared", "success");
2461
- return loadLogs();
2462
- })
2463
- .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
+ });
2464
3018
  });
2465
3019
 
2466
3020
  cliAddCopy.addEventListener("click", function () { copyText(cliAddPre.textContent, cliAddCopy); });
@@ -2886,7 +3440,8 @@ const clientJs = `
2886
3440
  });
2887
3441
 
2888
3442
  restartBtn.addEventListener("click", function () {
2889
- 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;
2890
3445
  var s = settingsLoaded;
2891
3446
  var nextPort = s ? ((s.env && s.env.httpPort) ? s.httpPort : ((s.saved && s.saved.httpPort) || s.httpPort)) : null;
2892
3447
  restartBtn.disabled = true;
@@ -2919,6 +3474,7 @@ const clientJs = `
2919
3474
  restartBtn.disabled = false;
2920
3475
  restartBtn.classList.remove("busy");
2921
3476
  });
3477
+ });
2922
3478
  });
2923
3479
 
2924
3480
  document.getElementById("set-home-copy").addEventListener("click", function (e) { copyText(setHome.textContent, e.currentTarget); });
@@ -3649,6 +4205,104 @@ function renderPage(nonce, token, uiAuth = false) {
3649
4205
  <a href="https://github.com/avinashid/locadot" target="_blank" rel="noopener noreferrer">github.com/avinashid/locadot</a>
3650
4206
  </footer>
3651
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>
3652
4306
  <div id="toast-container" class="toast-container" aria-live="polite"></div>
3653
4307
  <script nonce="${safeNonce}">${clientJs}</script>
3654
4308
  </body>