locadot 2.2.0 → 2.4.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,192 @@ 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 / protect dialogs ---------- */
816
+ #confirm-dialog, #prompt-dialog, #share-dialog, #protect-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, #protect-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
+ #protect-dialog { width: min(480px, calc(100vw - 32px)); }
829
+ .field-password { position: relative; }
830
+ .field-password .input { width: 100%; padding-right: 40px; }
831
+ .field-toggle {
832
+ position: absolute;
833
+ top: 4px;
834
+ right: 4px;
835
+ width: 28px;
836
+ height: 28px;
837
+ padding: 0;
838
+ border: 1px solid transparent;
839
+ border-radius: var(--radius-xs);
840
+ background: transparent;
841
+ color: var(--muted);
842
+ cursor: pointer;
843
+ display: inline-flex;
844
+ align-items: center;
845
+ justify-content: center;
846
+ }
847
+ .field-toggle:hover { background: var(--surface-2); color: var(--fg); }
848
+ .field-toggle svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
849
+ .option-check {
850
+ width: 16px;
851
+ height: 16px;
852
+ margin-top: 2px;
853
+ flex-shrink: 0;
854
+ border-radius: 4px;
855
+ border: 1.5px solid var(--border-strong);
856
+ background: var(--surface);
857
+ position: relative;
858
+ }
859
+ .option-card[aria-checked="true"] .option-check { border-color: var(--accent); background: var(--accent); }
860
+ .option-card[aria-checked="true"] .option-check::after {
861
+ content: "";
862
+ position: absolute;
863
+ left: 4px;
864
+ top: 1px;
865
+ width: 4px;
866
+ height: 8px;
867
+ border: solid var(--accent-fg);
868
+ border-width: 0 2px 2px 0;
869
+ transform: rotate(45deg);
870
+ }
871
+ #protect-remove { margin-right: auto; }
872
+ .share-options { display: flex; flex-direction: column; gap: 10px; }
873
+ .option-card {
874
+ display: flex;
875
+ align-items: flex-start;
876
+ gap: 12px;
877
+ width: 100%;
878
+ text-align: left;
879
+ padding: 14px;
880
+ border: 1px solid var(--border);
881
+ border-radius: var(--radius);
882
+ background: var(--surface-2);
883
+ color: inherit;
884
+ font: inherit;
885
+ cursor: pointer;
886
+ }
887
+ .option-card:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-bg); }
888
+ .option-card[aria-checked="true"] { border-color: var(--accent); background: var(--accent-bg); }
889
+ .option-dot {
890
+ width: 16px;
891
+ height: 16px;
892
+ margin-top: 2px;
893
+ flex-shrink: 0;
894
+ border-radius: 999px;
895
+ border: 1.5px solid var(--border-strong);
896
+ background: var(--surface);
897
+ position: relative;
898
+ }
899
+ .option-card[aria-checked="true"] .option-dot { border-color: var(--accent); }
900
+ .option-card[aria-checked="true"] .option-dot::after {
901
+ content: "";
902
+ position: absolute;
903
+ top: 3px;
904
+ left: 3px;
905
+ width: 8px;
906
+ height: 8px;
907
+ border-radius: 999px;
908
+ background: var(--accent);
909
+ }
910
+ .option-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
911
+ .option-title { font-size: 13.5px; font-weight: 600; }
912
+ .option-desc { font-size: 12.5px; color: var(--muted); line-height: 1.45; }
913
+ .option-extra { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
914
+ .option-extra .field-error { margin-top: 4px; }
915
+ .share-progress { display: flex; gap: 14px; align-items: flex-start; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-2); }
916
+ .share-progress-icon { width: 36px; height: 36px; flex-shrink: 0; border-radius: 999px; display: grid; place-items: center; background: var(--accent-bg); color: var(--accent); }
917
+ .share-progress-icon svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
918
+ .share-progress-icon .spinner { margin: 0; }
919
+ .share-progress-body { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
920
+ .share-progress-title { font-size: 14px; font-weight: 600; margin: 0; overflow-wrap: anywhere; }
921
+ .share-progress-text { font-size: 12.5px; color: var(--muted); line-height: 1.5; margin: 0; }
922
+ .share-progress-body .btn { align-self: flex-start; margin-top: 6px; text-decoration: none; }
923
+ .share-steps { margin: 2px 0 0; padding-left: 18px; font-size: 12.5px; color: var(--muted); line-height: 1.6; }
924
+
732
925
  /* ---------- toasts ---------- */
733
926
  .toast-container {
734
927
  position: fixed;
@@ -1080,6 +1273,8 @@ const clientJs = `
1080
1273
  var startupBusy = false;
1081
1274
  var cfInstallBusy = false;
1082
1275
  var cloudflaredInstalled = false;
1276
+ var cloudflareLoggedIn = false;
1277
+ var cloudflareZones = [];
1083
1278
  var proxyStopped = false;
1084
1279
  var pollTimer = null;
1085
1280
  var remotesPollTimer = null;
@@ -1297,6 +1492,8 @@ const clientJs = `
1297
1492
 
1298
1493
  var cf = sys.cloudflared || { installed: false };
1299
1494
  cloudflaredInstalled = !!cf.installed;
1495
+ cloudflareLoggedIn = !!cf.loggedIn;
1496
+ cloudflareZones = Array.isArray(cf.zones) ? cf.zones : [];
1300
1497
  if (cf.installed) {
1301
1498
  cfValueEl.textContent = "Installed";
1302
1499
  cfValueEl.className = "tile-value ok";
@@ -1378,11 +1575,13 @@ const clientJs = `
1378
1575
  revokeBtn.className = "btn btn-sm btn-danger";
1379
1576
  revokeBtn.textContent = "Revoke";
1380
1577
  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; });
1578
+ confirmDialog({ title: "Revoke " + peer.name + "?", message: "This peer will lose access immediately.", confirmLabel: "Revoke", danger: true }).then(function (ok) {
1579
+ if (!ok) return;
1580
+ revokeBtn.disabled = true;
1581
+ apiFetch("/api/peers/" + encodeURIComponent(peer.id), { method: "DELETE" })
1582
+ .then(function () { showToast("Revoked " + peer.name, "success"); return loadHub(); })
1583
+ .catch(function (err) { apiError(err, "Couldn't revoke peer"); revokeBtn.disabled = false; });
1584
+ });
1386
1585
  });
1387
1586
  controls.appendChild(revokeBtn);
1388
1587
  row.appendChild(controls);
@@ -1565,13 +1764,15 @@ const clientJs = `
1565
1764
  });
1566
1765
 
1567
1766
  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"); });
1767
+ confirmDialog({ title: "Stop remote access?", message: "Connected peers will be disconnected.", confirmLabel: "Stop remote access", danger: true }).then(function (ok) {
1768
+ if (!ok) return;
1769
+ hubStopBtn.disabled = true;
1770
+ hubStopBtn.classList.add("busy");
1771
+ apiFetch("/api/hub", { method: "POST", body: JSON.stringify({ mode: "off" }) })
1772
+ .then(function () { showToast("Remote access stopped", "success"); return loadHub(); })
1773
+ .catch(function (err) { apiError(err, "Couldn't stop remote access"); })
1774
+ .then(function () { hubStopBtn.disabled = false; hubStopBtn.classList.remove("busy"); });
1775
+ });
1575
1776
  });
1576
1777
 
1577
1778
  // ---------- remote access: connected machines (receiver) ----------
@@ -1633,11 +1834,12 @@ const clientJs = `
1633
1834
  urlEditBtn.className = "btn btn-sm btn-ghost";
1634
1835
  urlEditBtn.textContent = "Update URL";
1635
1836
  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"); });
1837
+ promptDialog({ title: "Update URL", message: "New URL for " + remote.name, label: "URL", value: remote.url, confirmLabel: "Save" }).then(function (next) {
1838
+ if (!next || !next.trim() || next.trim() === remote.url) return;
1839
+ apiFetch("/api/remotes/" + encodeURIComponent(remote.name), { method: "PUT", body: JSON.stringify({ url: next.trim() }) })
1840
+ .then(function () { showToast("Updated URL for " + remote.name, "success"); return loadRemotes(); })
1841
+ .catch(function (err) { apiError(err, "Couldn't update URL"); });
1842
+ });
1641
1843
  });
1642
1844
  urlRow.appendChild(urlEditBtn);
1643
1845
  item.appendChild(urlRow);
@@ -1663,11 +1865,13 @@ const clientJs = `
1663
1865
  disconnectBtn.className = "btn btn-sm btn-danger";
1664
1866
  disconnectBtn.textContent = "Disconnect";
1665
1867
  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; });
1868
+ confirmDialog({ title: "Disconnect from " + remote.name + "?", message: "This removes its hosts too.", confirmLabel: "Disconnect", danger: true }).then(function (ok) {
1869
+ if (!ok) return;
1870
+ disconnectBtn.disabled = true;
1871
+ apiFetch("/api/remotes/" + encodeURIComponent(remote.name), { method: "DELETE" })
1872
+ .then(function () { showToast("Disconnected " + remote.name, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
1873
+ .catch(function (err) { apiError(err, "Couldn't disconnect"); disconnectBtn.disabled = false; });
1874
+ });
1671
1875
  });
1672
1876
  actions.appendChild(disconnectBtn);
1673
1877
  item.appendChild(actions);
@@ -1718,11 +1922,13 @@ const clientJs = `
1718
1922
  delBtn.className = "btn btn-sm btn-danger";
1719
1923
  delBtn.textContent = "Delete";
1720
1924
  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; });
1925
+ confirmDialog({ title: "Delete " + h.host + " on " + remote.name + "?", message: "This can't be undone.", confirmLabel: "Delete", danger: true }).then(function (ok) {
1926
+ if (!ok) return;
1927
+ delBtn.disabled = true;
1928
+ apiFetch("/api/remotes/" + encodeURIComponent(remote.name) + "/hosts/" + encodeURIComponent(h.host), { method: "DELETE" })
1929
+ .then(function () { showToast("Deleted " + h.host, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
1930
+ .catch(function (err) { apiError(err, "Couldn't delete host"); delBtn.disabled = false; });
1931
+ });
1726
1932
  });
1727
1933
  hostRow.appendChild(delBtn);
1728
1934
  }
@@ -1961,29 +2167,31 @@ const clientJs = `
1961
2167
 
1962
2168
  stopProxyBtn.addEventListener("click", function () {
1963
2169
  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
- });
2170
+ confirmDialog({ title: "Stop the locadot proxy?", message: "Run \\"locadot start\\" to bring it back up.", confirmLabel: "Stop proxy", danger: true }).then(function (ok) {
2171
+ if (!ok) return;
2172
+ stopProxyBtn.disabled = true;
2173
+ stopProxyBtn.classList.add("busy");
2174
+ apiFetch("/api/proxy/stop", { method: "POST" })
2175
+ .then(function () {
2176
+ proxyStopped = true;
2177
+ clear(stoppedBanner);
2178
+ stoppedBanner.appendChild(document.createTextNode("Proxy stopped. Run "));
2179
+ var code = document.createElement("code");
2180
+ code.className = "mono";
2181
+ code.textContent = "locadot start";
2182
+ stoppedBanner.appendChild(code);
2183
+ stoppedBanner.appendChild(document.createTextNode(" to bring it back."));
2184
+ stoppedBanner.hidden = false;
2185
+ if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
2186
+ if (remotesPollTimer) { clearInterval(remotesPollTimer); remotesPollTimer = null; }
2187
+ showToast("Proxy stopped", "warn");
2188
+ })
2189
+ .catch(function (err) {
2190
+ apiError(err, "Couldn't stop the proxy");
2191
+ stopProxyBtn.disabled = false;
2192
+ stopProxyBtn.classList.remove("busy");
2193
+ });
2194
+ });
1987
2195
  });
1988
2196
 
1989
2197
  cfInstallBtn.addEventListener("click", function () {
@@ -2132,12 +2340,36 @@ const clientJs = `
2132
2340
  link.textContent = t.url.replace("https://", "");
2133
2341
  link.title = t.url;
2134
2342
  statusWrap.appendChild(link);
2343
+ if (t.mode === "custom") {
2344
+ var modeBadge = document.createElement("span");
2345
+ modeBadge.className = "badge badge-accent";
2346
+ modeBadge.textContent = "custom";
2347
+ statusWrap.appendChild(modeBadge);
2348
+ }
2135
2349
  statusWrap.appendChild(makeCopyButton(function () { return t.url; }));
2136
2350
  } else if (t.status === "starting") {
2137
2351
  var spin = document.createElement("span");
2138
2352
  spin.className = "spinner";
2139
2353
  statusWrap.appendChild(spin);
2140
2354
  statusWrap.appendChild(document.createTextNode("Starting\\u2026"));
2355
+ } else if (t.status === "login") {
2356
+ var loginDot = document.createElement("span");
2357
+ loginDot.className = "dot warn";
2358
+ statusWrap.appendChild(loginDot);
2359
+ var loginText = document.createElement("span");
2360
+ loginText.className = "tunnel-login";
2361
+ var loginLabel = document.createElement("span");
2362
+ loginLabel.textContent = "Waiting for Cloudflare login";
2363
+ loginText.appendChild(loginLabel);
2364
+ if (t.loginUrl) {
2365
+ var loginLink = document.createElement("a");
2366
+ loginLink.href = t.loginUrl;
2367
+ loginLink.target = "_blank";
2368
+ loginLink.rel = "noopener noreferrer";
2369
+ loginLink.textContent = "Log in to Cloudflare \u2197";
2370
+ loginText.appendChild(loginLink);
2371
+ }
2372
+ statusWrap.appendChild(loginText);
2141
2373
  } else if (t.status === "error") {
2142
2374
  var errDot = document.createElement("span");
2143
2375
  errDot.className = "dot down";
@@ -2159,35 +2391,692 @@ const clientJs = `
2159
2391
 
2160
2392
  function shareButton(row) {
2161
2393
  var t = tunnelInfo(row);
2162
- var isOn = t.enabled || t.status === "up" || t.status === "starting";
2394
+ var isOn = t.enabled || t.status === "up" || t.status === "starting" || t.status === "login";
2163
2395
  var isRetry = !isOn && t.status === "error";
2164
2396
  var shareBtn = document.createElement("button");
2165
2397
  shareBtn.type = "button";
2166
2398
  shareBtn.className = "btn btn-sm" + (isOn ? " btn-danger" : "");
2167
2399
  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");
2400
+ shareBtn.title = isOn ? "Stop the public tunnel" : "Share on a public URL";
2169
2401
  if (!cloudflaredInstalled && !isOn) {
2170
2402
  shareBtn.disabled = true;
2171
2403
  shareBtn.title = "Install cloudflared first";
2172
2404
  }
2173
2405
  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 }) })
2406
+ if (isOn) {
2407
+ confirmDialog({ title: "Stop sharing " + row.host + "?", message: "The public URL for " + row.host + " stops working.", confirmLabel: "Stop sharing", danger: true }).then(function (ok) {
2408
+ if (!ok) return;
2409
+ shareBtn.disabled = true;
2410
+ shareBtn.classList.add("busy");
2411
+ apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "PUT", body: JSON.stringify({ tunnel: false }) })
2412
+ .then(function () {
2413
+ showToast("Stopping tunnel for " + row.host, "success");
2414
+ return loadHosts();
2415
+ })
2416
+ .catch(function (err) {
2417
+ apiError(err, "Couldn't change sharing for " + row.host);
2418
+ shareBtn.disabled = false;
2419
+ shareBtn.classList.remove("busy");
2420
+ });
2421
+ });
2422
+ } else {
2423
+ openShareDialog(row);
2424
+ }
2425
+ });
2426
+ return shareBtn;
2427
+ }
2428
+
2429
+ // ---------- share dialog ----------
2430
+
2431
+ var shareDialogEl = document.getElementById("share-dialog");
2432
+ var shareHostEl = document.getElementById("share-host");
2433
+ var shareCloseBtn = document.getElementById("share-close");
2434
+ var shareCancelBtn = document.getElementById("share-cancel");
2435
+ var shareSubmitBtn = document.getElementById("share-submit");
2436
+ var shareOptionRandom = document.getElementById("share-option-random");
2437
+ var shareOptionCustom = document.getElementById("share-option-custom");
2438
+ var shareCustomExtra = document.getElementById("share-custom-extra");
2439
+ var shareDomainInput = document.getElementById("share-domain-input");
2440
+ var shareDomainError = document.getElementById("share-domain-error");
2441
+ var shareDomainHint = document.getElementById("share-domain-hint");
2442
+ var shareErrorEl = document.getElementById("share-error");
2443
+ var shareOptionsEl = document.getElementById("share-options");
2444
+ var shareProgressEl = document.getElementById("share-progress");
2445
+ var shareProgressIcon = document.getElementById("share-progress-icon");
2446
+ var shareProgressTitle = document.getElementById("share-progress-title");
2447
+ var shareProgressText = document.getElementById("share-progress-text");
2448
+ var shareStepsEl = document.getElementById("share-steps");
2449
+ var shareStepsDomain = document.getElementById("share-steps-domain");
2450
+ var shareAuthLink = document.getElementById("share-auth-link");
2451
+ var shareDescEl = shareDialogEl.querySelector(".modal-desc");
2452
+ var sharePoll = null;
2453
+ var shareAuthWin = null;
2454
+ var shareAuthOpened = false;
2455
+ var shareRow = null;
2456
+ var shareMode = "random";
2457
+ var HOSTNAME_RE = /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,}$/;
2458
+
2459
+ function normalizeHostname(value) {
2460
+ return String(value || "").trim().toLowerCase().replace(/^https?:\\/\\//, "").replace(/\\/$/, "");
2461
+ }
2462
+
2463
+ function validateHostname(raw) {
2464
+ var value = normalizeHostname(raw);
2465
+ if (!value) return "Enter a hostname.";
2466
+ if (value.slice(-10) === ".localhost") return "Hostname can't be a .localhost address.";
2467
+ if (!HOSTNAME_RE.test(value)) return "Enter a valid hostname, e.g. app.example.com.";
2468
+ return null;
2469
+ }
2470
+
2471
+ function setShareMode(mode) {
2472
+ shareMode = mode;
2473
+ shareOptionRandom.setAttribute("aria-checked", String(mode === "random"));
2474
+ shareOptionCustom.setAttribute("aria-checked", String(mode === "custom"));
2475
+ shareCustomExtra.hidden = mode !== "custom";
2476
+ updateDomainHint();
2477
+ shareDomainError.hidden = true;
2478
+ shareDomainError.textContent = "";
2479
+ shareErrorEl.hidden = true;
2480
+ shareErrorEl.textContent = "";
2481
+ if (mode === "custom") setTimeout(function () { shareDomainInput.focus(); }, 0);
2482
+ }
2483
+
2484
+ function coveringZone(hostname) {
2485
+ for (var i = 0; i < cloudflareZones.length; i++) {
2486
+ var zone = cloudflareZones[i];
2487
+ if (hostname === zone || hostname.slice(-(zone.length + 1)) === "." + zone) return zone;
2488
+ }
2489
+ return null;
2490
+ }
2491
+
2492
+ function updateDomainHint() {
2493
+ var value = normalizeHostname(shareDomainInput.value);
2494
+ var zone = value && !validateHostname(value) ? coveringZone(value) : null;
2495
+ shareDomainHint.textContent = zone
2496
+ ? "Uses your Cloudflare login for " + zone + ". The DNS record is created or updated for you."
2497
+ : "Cloudflare opens in a new tab so you can authorize this domain's zone. Each zone you authorize is remembered.";
2498
+ }
2499
+
2500
+ var ICON_CLOUD = '<svg viewBox="0 0 24 24"><path d="M7 18a4.5 4.5 0 0 1-.5-8.97A6 6 0 0 1 18 9.5a4 4 0 0 1-.5 8.5z"/></svg>';
2501
+
2502
+ function stopSharePoll() {
2503
+ if (sharePoll) clearInterval(sharePoll);
2504
+ sharePoll = null;
2505
+ }
2506
+
2507
+ function closeAuthWindow() {
2508
+ if (shareAuthWin && !shareAuthOpened && !shareAuthWin.closed) shareAuthWin.close();
2509
+ shareAuthWin = null;
2510
+ }
2511
+
2512
+ function showShareOptions() {
2513
+ shareOptionsEl.hidden = false;
2514
+ shareDescEl.hidden = false;
2515
+ shareProgressEl.hidden = true;
2516
+ shareSubmitBtn.hidden = false;
2517
+ shareCancelBtn.textContent = "Cancel";
2518
+ }
2519
+
2520
+ function showShareProgress(domain, t) {
2521
+ shareOptionsEl.hidden = true;
2522
+ shareDescEl.hidden = true;
2523
+ shareProgressEl.hidden = false;
2524
+ shareSubmitBtn.hidden = true;
2525
+ shareCancelBtn.textContent = "Close";
2526
+ shareCancelBtn.disabled = false;
2527
+ var login = t.status === "login";
2528
+ if (login) {
2529
+ shareProgressIcon.innerHTML = ICON_CLOUD;
2530
+ shareProgressTitle.textContent = "Authorize " + domain + " on Cloudflare";
2531
+ shareProgressText.textContent = "locadot doesn't have a Cloudflare login for this domain yet.";
2532
+ } else {
2533
+ shareProgressIcon.innerHTML = '<span class="spinner"></span>';
2534
+ shareProgressTitle.textContent = "Setting up " + domain;
2535
+ shareProgressText.textContent = t.loginUrl === undefined && shareAuthOpened
2536
+ ? "Authorized. Creating the tunnel and the DNS record\u2026"
2537
+ : "Checking your Cloudflare login, then creating the tunnel and the DNS record\u2026";
2538
+ }
2539
+ shareStepsEl.hidden = !login;
2540
+ shareStepsDomain.textContent = domain;
2541
+ shareAuthLink.hidden = !(login && t.loginUrl);
2542
+ if (login && t.loginUrl) {
2543
+ shareAuthLink.href = t.loginUrl;
2544
+ if (shareAuthWin && !shareAuthOpened && !shareAuthWin.closed) {
2545
+ shareAuthWin.location.href = t.loginUrl;
2546
+ shareAuthOpened = true;
2547
+ }
2548
+ }
2549
+ }
2550
+
2551
+ function watchShare(host, domain) {
2552
+ stopSharePoll();
2553
+ showShareProgress(domain, { status: "starting" });
2554
+ var tick = function () {
2555
+ fetch("/api/hosts").then(parseJsonOrThrow).then(function (hosts) {
2556
+ if (shareRow === null || shareRow.host !== host) return;
2557
+ var row = null;
2558
+ for (var i = 0; i < hosts.length; i++) if (hosts[i].host === host) row = hosts[i];
2559
+ var t = row ? tunnelInfo(row) : { status: "error", error: host + " was removed." };
2560
+ if (t.status === "up") {
2561
+ stopSharePoll();
2562
+ closeAuthWindow();
2563
+ closeShareDialog();
2564
+ showToast(host + " is live at " + t.url, "success");
2565
+ renderHosts(hosts);
2566
+ } else if (t.status === "error" || t.status === "off") {
2567
+ stopSharePoll();
2568
+ closeAuthWindow();
2569
+ showShareOptions();
2570
+ shareErrorEl.textContent = t.error || "Sharing stopped.";
2571
+ shareErrorEl.hidden = false;
2572
+ shareSubmitBtn.textContent = "Try again";
2573
+ renderHosts(hosts);
2574
+ } else {
2575
+ showShareProgress(domain, t);
2576
+ }
2577
+ }).catch(function () {});
2578
+ };
2579
+ sharePoll = setInterval(tick, 1500);
2580
+ tick();
2581
+ }
2582
+
2583
+ function closeShareDialog() {
2584
+ stopSharePoll();
2585
+ closeAuthWindow();
2586
+ shareRow = null;
2587
+ shareDialogEl.close();
2588
+ }
2589
+
2590
+ function openShareDialog(row) {
2591
+ shareRow = row;
2592
+ shareHostEl.textContent = row.host;
2593
+ shareErrorEl.hidden = true;
2594
+ shareErrorEl.textContent = "";
2595
+ shareDomainError.hidden = true;
2596
+ shareDomainError.textContent = "";
2597
+ var hasDomain = typeof row.tunnelDomain === "string" && !!row.tunnelDomain;
2598
+ shareDomainInput.value = hasDomain ? row.tunnelDomain : "";
2599
+ showShareOptions();
2600
+ shareSubmitBtn.textContent = "Start sharing";
2601
+ shareAuthOpened = false;
2602
+ setShareMode(hasDomain ? "custom" : "random");
2603
+ shareSubmitBtn.disabled = false;
2604
+ shareSubmitBtn.classList.remove("busy");
2605
+ shareCancelBtn.disabled = false;
2606
+ shareDialogEl.showModal();
2607
+ }
2608
+
2609
+ shareOptionRandom.addEventListener("click", function () { setShareMode("random"); });
2610
+ shareOptionCustom.addEventListener("click", function () { setShareMode("custom"); });
2611
+ shareOptionRandom.addEventListener("keydown", function (e) {
2612
+ if (e.key === "ArrowDown" || e.key === "ArrowRight") { e.preventDefault(); setShareMode("custom"); shareOptionCustom.focus(); }
2613
+ });
2614
+ shareOptionCustom.addEventListener("keydown", function (e) {
2615
+ if (e.key === "ArrowUp" || e.key === "ArrowLeft") { e.preventDefault(); setShareMode("random"); shareOptionRandom.focus(); }
2616
+ });
2617
+ shareCancelBtn.addEventListener("click", closeShareDialog);
2618
+ shareCloseBtn.addEventListener("click", closeShareDialog);
2619
+ shareDialogEl.addEventListener("click", function (e) {
2620
+ if (e.target === shareDialogEl) closeShareDialog();
2621
+ });
2622
+ shareDialogEl.addEventListener("close", function () {
2623
+ stopSharePoll();
2624
+ closeAuthWindow();
2625
+ shareRow = null;
2626
+ });
2627
+ shareDomainInput.addEventListener("input", updateDomainHint);
2628
+ shareDomainInput.addEventListener("keydown", function (e) {
2629
+ if (e.key === "Enter") { e.preventDefault(); shareSubmitBtn.click(); }
2630
+ });
2631
+
2632
+ shareSubmitBtn.addEventListener("click", function () {
2633
+ if (!shareRow) return;
2634
+ var row = shareRow;
2635
+ var body = { tunnel: true };
2636
+ if (shareMode === "custom") {
2637
+ var err = validateHostname(shareDomainInput.value);
2638
+ if (err) {
2639
+ shareDomainError.textContent = err;
2640
+ shareDomainError.hidden = false;
2641
+ return;
2642
+ }
2643
+ body.domain = normalizeHostname(shareDomainInput.value);
2644
+ }
2645
+ shareSubmitBtn.disabled = true;
2646
+ shareSubmitBtn.classList.add("busy");
2647
+ shareCancelBtn.disabled = true;
2648
+ shareErrorEl.hidden = true;
2649
+ // Opened now, inside the click, so the browser allows it; pointed at Cloudflare once the sign-in URL arrives.
2650
+ shareAuthOpened = false;
2651
+ closeAuthWindow();
2652
+ if (body.domain && !coveringZone(body.domain)) {
2653
+ shareAuthWin = window.open("", "_blank");
2654
+ if (shareAuthWin) {
2655
+ try {
2656
+ shareAuthWin.document.title = "Connecting to Cloudflare\u2026";
2657
+ shareAuthWin.document.body.style.cssText = "font:14px system-ui,sans-serif;color:#666;display:grid;place-items:center;height:100vh;margin:0";
2658
+ shareAuthWin.document.body.textContent = "Connecting to Cloudflare\u2026";
2659
+ } catch (e) {}
2660
+ }
2661
+ }
2662
+ apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "PUT", body: JSON.stringify(body) })
2663
+ .then(function () {
2664
+ if (body.domain) {
2665
+ watchShare(row.host, body.domain);
2666
+ return loadHosts();
2667
+ }
2668
+ showToast("Starting tunnel for " + row.host, "success");
2669
+ closeShareDialog();
2670
+ return loadHosts();
2671
+ })
2672
+ .catch(function (err2) {
2673
+ closeAuthWindow();
2674
+ shareErrorEl.textContent = (err2 && err2.message) ? err2.message : String(err2);
2675
+ shareErrorEl.hidden = false;
2676
+ })
2677
+ .then(function () {
2678
+ shareSubmitBtn.disabled = false;
2679
+ shareSubmitBtn.classList.remove("busy");
2680
+ shareCancelBtn.disabled = false;
2681
+ });
2682
+ });
2683
+
2684
+ var allowDialog = document.getElementById("allow-dialog");
2685
+ var allowHostEl = document.getElementById("allow-host");
2686
+ var allowWarnEl = document.getElementById("allow-warn");
2687
+ var allowListEl = document.getElementById("allow-list");
2688
+ var allowInput = document.getElementById("allow-input");
2689
+ var allowAddBtn = document.getElementById("allow-add");
2690
+ var allowErrorEl = document.getElementById("allow-error");
2691
+ var allowCancelBtn = document.getElementById("allow-cancel");
2692
+ var allowSaveBtn = document.getElementById("allow-save");
2693
+ var allowCloseBtn = document.getElementById("allow-close");
2694
+ var allowOriginal = [];
2695
+ var allowCurrent = [];
2696
+ var allowRow = null;
2697
+
2698
+ function allowListsEqual(a, b) {
2699
+ if (a.length !== b.length) return false;
2700
+ for (var i = 0; i < a.length; i++) {
2701
+ if (a[i] !== b[i]) return false;
2702
+ }
2703
+ return true;
2704
+ }
2705
+
2706
+ function updateAllowSave() {
2707
+ allowSaveBtn.disabled = allowListsEqual(allowCurrent, allowOriginal);
2708
+ }
2709
+
2710
+ function renderAllowList() {
2711
+ clear(allowListEl);
2712
+ if (!allowCurrent.length) {
2713
+ var empty = document.createElement("div");
2714
+ empty.className = "dim allow-empty";
2715
+ empty.textContent = "No internal addresses. Shared visitors reach public hosts only.";
2716
+ allowListEl.appendChild(empty);
2717
+ return;
2718
+ }
2719
+ allowCurrent.forEach(function (entry) {
2720
+ var entryRow = document.createElement("div");
2721
+ entryRow.className = "allow-row";
2722
+ var text = document.createElement("span");
2723
+ text.className = "mono";
2724
+ text.textContent = entry;
2725
+ entryRow.appendChild(text);
2726
+ var removeBtn = document.createElement("button");
2727
+ removeBtn.type = "button";
2728
+ removeBtn.className = "allow-remove";
2729
+ removeBtn.textContent = "\\u00d7";
2730
+ removeBtn.setAttribute("aria-label", "Remove " + entry);
2731
+ removeBtn.addEventListener("click", function () {
2732
+ allowCurrent = allowCurrent.filter(function (item) { return item !== entry; });
2733
+ renderAllowList();
2734
+ updateAllowSave();
2735
+ });
2736
+ entryRow.appendChild(removeBtn);
2737
+ allowListEl.appendChild(entryRow);
2738
+ });
2739
+ }
2740
+
2741
+ function allowAddEntry() {
2742
+ var value = allowInput.value.trim().toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\\/\\//, "").replace(/\\/$/, "");
2743
+ allowErrorEl.hidden = true;
2744
+ allowErrorEl.textContent = "";
2745
+ if (!value || /\\s/.test(value)) {
2746
+ allowErrorEl.textContent = "Enter a host or host:port, e.g. localhost:3000.";
2747
+ allowErrorEl.hidden = false;
2748
+ return;
2749
+ }
2750
+ if (allowCurrent.indexOf(value) !== -1) {
2751
+ allowErrorEl.textContent = value + " is already in the list.";
2752
+ allowErrorEl.hidden = false;
2753
+ return;
2754
+ }
2755
+ allowCurrent.push(value);
2756
+ allowInput.value = "";
2757
+ renderAllowList();
2758
+ updateAllowSave();
2759
+ }
2760
+
2761
+ function closeAllowDialog() {
2762
+ allowDialog.close();
2763
+ }
2764
+
2765
+ function openAllowDialog(row) {
2766
+ allowRow = row;
2767
+ allowOriginal = (row.allow || []).slice();
2768
+ allowCurrent = allowOriginal.slice();
2769
+ allowHostEl.textContent = row.host;
2770
+ allowWarnEl.hidden = !!row.cors;
2771
+ allowErrorEl.hidden = true;
2772
+ allowErrorEl.textContent = "";
2773
+ allowInput.value = "";
2774
+ renderAllowList();
2775
+ allowSaveBtn.disabled = true;
2776
+ allowSaveBtn.classList.remove("busy");
2777
+ allowCancelBtn.disabled = false;
2778
+ allowDialog.showModal();
2779
+ setTimeout(function () { allowInput.focus(); }, 0);
2780
+ }
2781
+
2782
+ allowAddBtn.addEventListener("click", allowAddEntry);
2783
+ allowInput.addEventListener("keydown", function (e) {
2784
+ if (e.key === "Enter") { e.preventDefault(); allowAddEntry(); }
2785
+ });
2786
+ allowCancelBtn.addEventListener("click", closeAllowDialog);
2787
+ allowCloseBtn.addEventListener("click", closeAllowDialog);
2788
+ allowDialog.addEventListener("click", function (e) {
2789
+ if (e.target === allowDialog) closeAllowDialog();
2790
+ });
2791
+
2792
+ allowSaveBtn.addEventListener("click", function () {
2793
+ if (!allowRow) return;
2794
+ var host = allowRow.host;
2795
+ allowSaveBtn.disabled = true;
2796
+ allowSaveBtn.classList.add("busy");
2797
+ allowCancelBtn.disabled = true;
2798
+ apiFetch("/api/hosts/" + encodeURIComponent(host), { method: "PUT", body: JSON.stringify({ allow: allowCurrent }) })
2799
+ .then(function () {
2800
+ showToast("Saved internal access for " + host, "success");
2801
+ closeAllowDialog();
2802
+ return loadHosts();
2803
+ })
2804
+ .catch(function (err) {
2805
+ allowErrorEl.textContent = err && err.message ? err.message : String(err);
2806
+ allowErrorEl.hidden = false;
2807
+ allowSaveBtn.disabled = false;
2808
+ allowSaveBtn.classList.remove("busy");
2809
+ allowCancelBtn.disabled = false;
2810
+ });
2811
+ });
2812
+
2813
+ // ---------- protect dialog ----------
2814
+
2815
+ var PROTECT_SCOPES = ["shared", "remote", "local"];
2816
+ var PROTECT_SCOPE_LABELS = { shared: "Shared link", remote: "Remote access", local: "This machine & network" };
2817
+
2818
+ function protectScopeNames(scopes) {
2819
+ return scopes.map(function (s) { return PROTECT_SCOPE_LABELS[s] || s; }).join(", ");
2820
+ }
2821
+
2822
+ var protectDialogEl = document.getElementById("protect-dialog");
2823
+ var protectHostEl = document.getElementById("protect-host");
2824
+ var protectCloseBtn = document.getElementById("protect-close");
2825
+ var protectCancelBtn = document.getElementById("protect-cancel");
2826
+ var protectSubmitBtn = document.getElementById("protect-submit");
2827
+ var protectRemoveBtn = document.getElementById("protect-remove");
2828
+ var protectPasswordInput = document.getElementById("protect-password");
2829
+ var protectToggleBtn = document.getElementById("protect-toggle");
2830
+ var protectErrorEl = document.getElementById("protect-error");
2831
+ var protectHintEl = document.getElementById("protect-hint");
2832
+ var protectScopeErrorEl = document.getElementById("protect-scope-error");
2833
+ var protectOptionEls = PROTECT_SCOPES.map(function (s) { return document.getElementById("protect-option-" + s); });
2834
+ var protectRow = null;
2835
+ var protectScopes = [];
2836
+
2837
+ function protectRenderScopes() {
2838
+ protectOptionEls.forEach(function (el) {
2839
+ el.setAttribute("aria-checked", String(protectScopes.indexOf(el.getAttribute("data-scope")) !== -1));
2840
+ });
2841
+ }
2842
+
2843
+ function protectToggleScope(el) {
2844
+ var scope = el.getAttribute("data-scope");
2845
+ var on = protectScopes.indexOf(scope) === -1;
2846
+ if (on) protectScopes.push(scope);
2847
+ else protectScopes = protectScopes.filter(function (s) { return s !== scope; });
2848
+ protectRenderScopes();
2849
+ protectScopeErrorEl.hidden = true;
2850
+ protectScopeErrorEl.textContent = "";
2851
+ }
2852
+
2853
+ protectOptionEls.forEach(function (el) {
2854
+ el.addEventListener("click", function () { protectToggleScope(el); });
2855
+ });
2856
+
2857
+ protectToggleBtn.addEventListener("click", function () {
2858
+ var showing = protectPasswordInput.type === "text";
2859
+ protectPasswordInput.type = showing ? "password" : "text";
2860
+ protectToggleBtn.setAttribute("aria-label", showing ? "Show password" : "Hide password");
2861
+ });
2862
+
2863
+ function closeProtectDialog() {
2864
+ protectRow = null;
2865
+ protectDialogEl.close();
2866
+ }
2867
+
2868
+ function openProtectDialog(row) {
2869
+ protectRow = row;
2870
+ protectHostEl.textContent = row.host;
2871
+ protectPasswordInput.value = "";
2872
+ protectPasswordInput.type = "password";
2873
+ protectToggleBtn.setAttribute("aria-label", "Show password");
2874
+ protectErrorEl.hidden = true;
2875
+ protectErrorEl.textContent = "";
2876
+ protectScopeErrorEl.hidden = true;
2877
+ protectScopeErrorEl.textContent = "";
2878
+ protectScopes = row.protect ? row.protect.scopes.slice() : [];
2879
+ protectRenderScopes();
2880
+ if (row.protect) {
2881
+ protectPasswordInput.placeholder = "Leave blank to keep the current password";
2882
+ protectHintEl.textContent = "Set " + fmtRelative(row.protect.updatedAt);
2883
+ protectHintEl.hidden = false;
2884
+ protectRemoveBtn.hidden = false;
2885
+ protectSubmitBtn.textContent = "Save";
2886
+ } else {
2887
+ protectPasswordInput.placeholder = "";
2888
+ protectHintEl.hidden = true;
2889
+ protectRemoveBtn.hidden = true;
2890
+ protectSubmitBtn.textContent = "Protect host";
2891
+ }
2892
+ protectSubmitBtn.disabled = false;
2893
+ protectSubmitBtn.classList.remove("busy");
2894
+ protectCancelBtn.disabled = false;
2895
+ protectRemoveBtn.disabled = false;
2896
+ protectDialogEl.showModal();
2897
+ setTimeout(function () { protectPasswordInput.focus(); }, 0);
2898
+ }
2899
+
2900
+ protectCancelBtn.addEventListener("click", closeProtectDialog);
2901
+ protectCloseBtn.addEventListener("click", closeProtectDialog);
2902
+ protectDialogEl.addEventListener("click", function (e) {
2903
+ if (e.target === protectDialogEl) closeProtectDialog();
2904
+ });
2905
+ protectDialogEl.addEventListener("close", function () { protectRow = null; });
2906
+
2907
+ protectRemoveBtn.addEventListener("click", function () {
2908
+ if (!protectRow) return;
2909
+ var row = protectRow;
2910
+ confirmDialog({ title: "Remove password protection?", message: "Anyone will be able to reach " + row.host + " without a password.", confirmLabel: "Remove protection", danger: true }).then(function (ok) {
2911
+ if (!ok) return;
2912
+ protectRemoveBtn.disabled = true;
2913
+ protectSubmitBtn.disabled = true;
2914
+ protectCancelBtn.disabled = true;
2915
+ apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "PUT", body: JSON.stringify({ protect: null }) })
2178
2916
  .then(function () {
2179
- showToast(isOn ? "Stopping tunnel for " + row.host : "Starting tunnel for " + row.host, "success");
2917
+ showToast("Removed password protection for " + row.host, "success");
2918
+ closeProtectDialog();
2180
2919
  return loadHosts();
2181
2920
  })
2182
2921
  .catch(function (err) {
2183
- apiError(err, "Couldn't change sharing for " + row.host);
2184
- shareBtn.disabled = false;
2185
- shareBtn.classList.remove("busy");
2922
+ protectErrorEl.textContent = err && err.message ? err.message : String(err);
2923
+ protectErrorEl.hidden = false;
2924
+ protectRemoveBtn.disabled = false;
2925
+ protectSubmitBtn.disabled = false;
2926
+ protectCancelBtn.disabled = false;
2186
2927
  });
2187
2928
  });
2188
- return shareBtn;
2929
+ });
2930
+
2931
+ protectSubmitBtn.addEventListener("click", function () {
2932
+ if (!protectRow) return;
2933
+ var row = protectRow;
2934
+ var password = protectPasswordInput.value;
2935
+ protectErrorEl.hidden = true;
2936
+ protectErrorEl.textContent = "";
2937
+ protectScopeErrorEl.hidden = true;
2938
+ protectScopeErrorEl.textContent = "";
2939
+ var required = !row.protect;
2940
+ if ((required || password) && password.length < 8) {
2941
+ protectErrorEl.textContent = "Use at least 8 characters.";
2942
+ protectErrorEl.hidden = false;
2943
+ return;
2944
+ }
2945
+ if (!protectScopes.length) {
2946
+ protectScopeErrorEl.textContent = "Pick at least one place to ask for the password.";
2947
+ protectScopeErrorEl.hidden = false;
2948
+ return;
2949
+ }
2950
+ var protectBody = { scopes: protectScopes.slice() };
2951
+ if (password) protectBody.password = password;
2952
+ protectSubmitBtn.disabled = true;
2953
+ protectSubmitBtn.classList.add("busy");
2954
+ protectCancelBtn.disabled = true;
2955
+ protectRemoveBtn.disabled = true;
2956
+ apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "PUT", body: JSON.stringify({ protect: protectBody }) })
2957
+ .then(function () {
2958
+ showToast("Password protection saved for " + row.host, "success");
2959
+ closeProtectDialog();
2960
+ return loadHosts();
2961
+ })
2962
+ .catch(function (err) {
2963
+ protectErrorEl.textContent = err && err.message ? err.message : String(err);
2964
+ protectErrorEl.hidden = false;
2965
+ })
2966
+ .then(function () {
2967
+ protectSubmitBtn.disabled = false;
2968
+ protectSubmitBtn.classList.remove("busy");
2969
+ protectCancelBtn.disabled = false;
2970
+ protectRemoveBtn.disabled = false;
2971
+ });
2972
+ });
2973
+
2974
+ // ---------- confirm / prompt dialogs ----------
2975
+
2976
+ var confirmDialogEl = document.getElementById("confirm-dialog");
2977
+ var confirmTitleEl = document.getElementById("confirm-title");
2978
+ var confirmMessageEl = document.getElementById("confirm-message");
2979
+ var confirmCancelBtn = document.getElementById("confirm-cancel");
2980
+ var confirmOkBtn = document.getElementById("confirm-ok");
2981
+ var confirmCloseBtn = document.getElementById("confirm-close");
2982
+ var confirmResolve = null;
2983
+
2984
+ function closeConfirmDialog(result) {
2985
+ var resolve = confirmResolve;
2986
+ confirmResolve = null;
2987
+ confirmDialogEl.close();
2988
+ if (resolve) resolve(result);
2189
2989
  }
2190
2990
 
2991
+ function confirmDialog(opts) {
2992
+ opts = opts || {};
2993
+ return new Promise(function (resolve) {
2994
+ confirmResolve = resolve;
2995
+ confirmTitleEl.textContent = opts.title || "Are you sure?";
2996
+ confirmMessageEl.textContent = opts.message || "";
2997
+ confirmOkBtn.textContent = opts.confirmLabel || "OK";
2998
+ confirmOkBtn.className = "btn " + (opts.danger ? "btn-danger" : "btn-primary");
2999
+ confirmDialogEl.showModal();
3000
+ setTimeout(function () { confirmOkBtn.focus(); }, 0);
3001
+ });
3002
+ }
3003
+
3004
+ confirmCancelBtn.addEventListener("click", function () { closeConfirmDialog(false); });
3005
+ confirmCloseBtn.addEventListener("click", function () { closeConfirmDialog(false); });
3006
+ confirmOkBtn.addEventListener("click", function () { closeConfirmDialog(true); });
3007
+ confirmDialogEl.addEventListener("click", function (e) {
3008
+ if (e.target === confirmDialogEl) closeConfirmDialog(false);
3009
+ });
3010
+ confirmDialogEl.addEventListener("close", function () {
3011
+ if (confirmResolve) { var resolve = confirmResolve; confirmResolve = null; resolve(false); }
3012
+ });
3013
+
3014
+ var promptDialogEl = document.getElementById("prompt-dialog");
3015
+ var promptTitleEl = document.getElementById("prompt-title");
3016
+ var promptMessageEl = document.getElementById("prompt-message");
3017
+ var promptLabelEl = document.getElementById("prompt-label");
3018
+ var promptInput = document.getElementById("prompt-input");
3019
+ var promptErrorEl = document.getElementById("prompt-error");
3020
+ var promptCancelBtn = document.getElementById("prompt-cancel");
3021
+ var promptConfirmBtn = document.getElementById("prompt-confirm");
3022
+ var promptCloseBtn = document.getElementById("prompt-close");
3023
+ var promptResolve = null;
3024
+ var promptValidate = null;
3025
+
3026
+ function closePromptDialog(result) {
3027
+ var resolve = promptResolve;
3028
+ promptResolve = null;
3029
+ promptValidate = null;
3030
+ promptDialogEl.close();
3031
+ if (resolve) resolve(result);
3032
+ }
3033
+
3034
+ function promptDialog(opts) {
3035
+ opts = opts || {};
3036
+ return new Promise(function (resolve) {
3037
+ promptResolve = resolve;
3038
+ promptValidate = typeof opts.validate === "function" ? opts.validate : null;
3039
+ promptTitleEl.textContent = opts.title || "";
3040
+ promptMessageEl.textContent = opts.message || "";
3041
+ promptMessageEl.hidden = !opts.message;
3042
+ promptLabelEl.textContent = opts.label || "";
3043
+ promptLabelEl.hidden = !opts.label;
3044
+ promptInput.placeholder = opts.placeholder || "";
3045
+ promptInput.value = opts.value || "";
3046
+ promptConfirmBtn.textContent = opts.confirmLabel || "OK";
3047
+ promptErrorEl.hidden = true;
3048
+ promptErrorEl.textContent = "";
3049
+ promptDialogEl.showModal();
3050
+ setTimeout(function () { promptInput.focus(); promptInput.select(); }, 0);
3051
+ });
3052
+ }
3053
+
3054
+ function submitPromptDialog() {
3055
+ var value = promptInput.value;
3056
+ if (promptValidate) {
3057
+ var err = promptValidate(value);
3058
+ if (err) {
3059
+ promptErrorEl.textContent = err;
3060
+ promptErrorEl.hidden = false;
3061
+ return;
3062
+ }
3063
+ }
3064
+ closePromptDialog(value);
3065
+ }
3066
+
3067
+ promptCancelBtn.addEventListener("click", function () { closePromptDialog(null); });
3068
+ promptCloseBtn.addEventListener("click", function () { closePromptDialog(null); });
3069
+ promptConfirmBtn.addEventListener("click", submitPromptDialog);
3070
+ promptInput.addEventListener("keydown", function (e) {
3071
+ if (e.key === "Enter") { e.preventDefault(); submitPromptDialog(); }
3072
+ });
3073
+ promptDialogEl.addEventListener("click", function (e) {
3074
+ if (e.target === promptDialogEl) closePromptDialog(null);
3075
+ });
3076
+ promptDialogEl.addEventListener("close", function () {
3077
+ if (promptResolve) { var resolve = promptResolve; promptResolve = null; resolve(null); }
3078
+ });
3079
+
2191
3080
  function labelCells(tr, labels) {
2192
3081
  for (var i = 0; i < tr.children.length; i++) tr.children[i].setAttribute("data-label", labels[i] || "");
2193
3082
  }
@@ -2228,13 +3117,15 @@ const clientJs = `
2228
3117
 
2229
3118
  var optionsTd = document.createElement("td");
2230
3119
  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"]);
3120
+ if (row.insecure) flags.push(["insecure", "Upstream TLS certificate is not verified", "badge-warn"]);
3121
+ if (row.cors) flags.push(["cors", "Origin/Referer rewritten to the target; any origin may call this host", "badge-cors"]);
3122
+ if (row.allow && row.allow.length) flags.push(["allow " + row.allow.length, row.allow.join(", "), "badge-accent"]);
3123
+ if (row.protect) flags.push(["password", "Password protected on: " + protectScopeNames(row.protect.scopes), "badge-accent"]);
2233
3124
  if (flags.length) {
2234
3125
  optionsTd.className = "badges";
2235
3126
  flags.forEach(function (flag) {
2236
3127
  var badge = document.createElement("span");
2237
- badge.className = "badge " + (flag[0] === "cors" ? "badge-cors" : "badge-warn");
3128
+ badge.className = "badge " + flag[2];
2238
3129
  badge.textContent = flag[0];
2239
3130
  badge.title = flag[1];
2240
3131
  optionsTd.appendChild(badge);
@@ -2265,6 +3156,34 @@ const clientJs = `
2265
3156
  var actionsWrap = document.createElement("div");
2266
3157
  actionsWrap.className = "row-actions";
2267
3158
  actionsTd.appendChild(actionsWrap);
3159
+ var accessBtn = document.createElement("button");
3160
+ accessBtn.type = "button";
3161
+ var allowCount = row.allow ? row.allow.length : 0;
3162
+ accessBtn.className = "btn btn-sm btn-icon" + (allowCount ? " is-set" : "");
3163
+ accessBtn.title = allowCount ? "Internal access: " + row.allow.join(", ") : "Internal access: let shared visitors reach addresses such as localhost:3000";
3164
+ accessBtn.setAttribute("aria-label", "Internal access for " + row.host);
3165
+ var accessSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
3166
+ accessSvg.setAttribute("viewBox", "0 0 24 24");
3167
+ accessSvg.setAttribute("aria-hidden", "true");
3168
+ var accessPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
3169
+ 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");
3170
+ accessSvg.appendChild(accessPath);
3171
+ accessBtn.appendChild(accessSvg);
3172
+ accessBtn.addEventListener("click", function () { openAllowDialog(row); });
3173
+ var protectBtn = document.createElement("button");
3174
+ protectBtn.type = "button";
3175
+ var protectScopesSet = row.protect ? row.protect.scopes : [];
3176
+ protectBtn.className = "btn btn-sm btn-icon" + (protectScopesSet.length ? " is-set" : "");
3177
+ protectBtn.title = protectScopesSet.length ? "Password protected on: " + protectScopeNames(protectScopesSet) : "Password protection: require a password before visitors reach this host";
3178
+ protectBtn.setAttribute("aria-label", "Password protection for " + row.host);
3179
+ var protectSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
3180
+ protectSvg.setAttribute("viewBox", "0 0 24 24");
3181
+ protectSvg.setAttribute("aria-hidden", "true");
3182
+ var protectPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
3183
+ protectPath.setAttribute("d", "M7 11V8a5 5 0 0 1 10 0v3M5 11h14v10H5z");
3184
+ protectSvg.appendChild(protectPath);
3185
+ protectBtn.appendChild(protectSvg);
3186
+ protectBtn.addEventListener("click", function () { openProtectDialog(row); });
2268
3187
  var editBtn = document.createElement("button");
2269
3188
  editBtn.type = "button";
2270
3189
  editBtn.className = "btn btn-sm";
@@ -2275,20 +3194,24 @@ const clientJs = `
2275
3194
  removeBtn.className = "btn btn-sm btn-danger";
2276
3195
  removeBtn.textContent = "Remove";
2277
3196
  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
- });
3197
+ (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) {
3198
+ if (!ok) return;
3199
+ removeBtn.disabled = true;
3200
+ apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "DELETE" })
3201
+ .then(function () {
3202
+ showToast("Removed " + row.host, "success");
3203
+ return loadHosts();
3204
+ })
3205
+ .catch(function (err) {
3206
+ apiError(err, "Couldn't remove " + row.host);
3207
+ removeBtn.disabled = false;
3208
+ });
3209
+ });
2289
3210
  });
2290
3211
  if (!row.remote) {
2291
3212
  actionsWrap.appendChild(shareButton(row));
3213
+ actionsWrap.appendChild(accessBtn);
3214
+ actionsWrap.appendChild(protectBtn);
2292
3215
  actionsWrap.appendChild(editBtn);
2293
3216
  }
2294
3217
  actionsWrap.appendChild(removeBtn);
@@ -2454,13 +3377,15 @@ const clientJs = `
2454
3377
 
2455
3378
  logsRefreshBtn.addEventListener("click", loadLogs);
2456
3379
  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"); });
3380
+ confirmDialog({ title: "Clear logs?", message: "This can't be undone.", confirmLabel: "Clear logs", danger: true }).then(function (ok) {
3381
+ if (!ok) return;
3382
+ apiFetch("/api/logs/clear", { method: "POST" })
3383
+ .then(function () {
3384
+ showToast("Logs cleared", "success");
3385
+ return loadLogs();
3386
+ })
3387
+ .catch(function (err) { apiError(err, "Couldn't clear logs"); });
3388
+ });
2464
3389
  });
2465
3390
 
2466
3391
  cliAddCopy.addEventListener("click", function () { copyText(cliAddPre.textContent, cliAddCopy); });
@@ -2886,7 +3811,8 @@ const clientJs = `
2886
3811
  });
2887
3812
 
2888
3813
  restartBtn.addEventListener("click", function () {
2889
- if (!window.confirm("Restart the proxy? Hosts are unavailable for a few seconds.")) return;
3814
+ confirmDialog({ title: "Restart the proxy?", message: "Hosts are unavailable for a few seconds.", confirmLabel: "Restart", danger: true }).then(function (ok) {
3815
+ if (!ok) return;
2890
3816
  var s = settingsLoaded;
2891
3817
  var nextPort = s ? ((s.env && s.env.httpPort) ? s.httpPort : ((s.saved && s.saved.httpPort) || s.httpPort)) : null;
2892
3818
  restartBtn.disabled = true;
@@ -2919,6 +3845,7 @@ const clientJs = `
2919
3845
  restartBtn.disabled = false;
2920
3846
  restartBtn.classList.remove("busy");
2921
3847
  });
3848
+ });
2922
3849
  });
2923
3850
 
2924
3851
  document.getElementById("set-home-copy").addEventListener("click", function (e) { copyText(setHome.textContent, e.currentTarget); });
@@ -3649,6 +4576,172 @@ function renderPage(nonce, token, uiAuth = false) {
3649
4576
  <a href="https://github.com/avinashid/locadot" target="_blank" rel="noopener noreferrer">github.com/avinashid/locadot</a>
3650
4577
  </footer>
3651
4578
  </div>
4579
+ <dialog id="allow-dialog">
4580
+ <div class="modal-head">
4581
+ <div>
4582
+ <h3 class="modal-title">Internal access</h3>
4583
+ <p id="allow-host" class="modal-sub mono"></p>
4584
+ </div>
4585
+ <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>
4586
+ </div>
4587
+ <div class="modal-body">
4588
+ <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>
4589
+ <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>
4590
+ <div id="allow-list" class="allow-list"></div>
4591
+ <div class="allow-add-row">
4592
+ <input type="text" id="allow-input" class="input" placeholder="localhost:3000" aria-label="Address" autocomplete="off">
4593
+ <button type="button" id="allow-add" class="btn btn-sm">Add</button>
4594
+ </div>
4595
+ <p class="modal-hint">host:port, or a host alone for any port. locadot's own ports are always blocked.</p>
4596
+ <div id="allow-error" class="field-error" role="alert" hidden></div>
4597
+ </div>
4598
+ <div class="modal-foot">
4599
+ <button type="button" id="allow-cancel" class="btn">Cancel</button>
4600
+ <button type="button" id="allow-save" class="btn btn-primary" disabled>Save changes</button>
4601
+ </div>
4602
+ </dialog>
4603
+ <dialog id="confirm-dialog">
4604
+ <div class="modal-head">
4605
+ <div>
4606
+ <h3 class="modal-title" id="confirm-title"></h3>
4607
+ </div>
4608
+ <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>
4609
+ </div>
4610
+ <div class="modal-body">
4611
+ <p class="modal-desc" id="confirm-message"></p>
4612
+ </div>
4613
+ <div class="modal-foot">
4614
+ <button type="button" id="confirm-cancel" class="btn">Cancel</button>
4615
+ <button type="button" id="confirm-ok" class="btn btn-primary">OK</button>
4616
+ </div>
4617
+ </dialog>
4618
+ <dialog id="prompt-dialog">
4619
+ <div class="modal-head">
4620
+ <div>
4621
+ <h3 class="modal-title" id="prompt-title"></h3>
4622
+ </div>
4623
+ <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>
4624
+ </div>
4625
+ <div class="modal-body">
4626
+ <p class="modal-desc" id="prompt-message"></p>
4627
+ <div class="field">
4628
+ <label id="prompt-label" for="prompt-input"></label>
4629
+ <input type="text" id="prompt-input" class="input" autocomplete="off">
4630
+ </div>
4631
+ <div id="prompt-error" class="field-error" role="alert" hidden></div>
4632
+ </div>
4633
+ <div class="modal-foot">
4634
+ <button type="button" id="prompt-cancel" class="btn">Cancel</button>
4635
+ <button type="button" id="prompt-confirm" class="btn btn-primary">OK</button>
4636
+ </div>
4637
+ </dialog>
4638
+ <dialog id="share-dialog">
4639
+ <div class="modal-head">
4640
+ <div>
4641
+ <h3 class="modal-title">Share publicly</h3>
4642
+ <p id="share-host" class="modal-sub mono"></p>
4643
+ </div>
4644
+ <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>
4645
+ </div>
4646
+ <div class="modal-body">
4647
+ <p class="modal-desc">Anyone with the link will be able to reach this host, without signing in.</p>
4648
+ <div id="share-options" class="share-options" role="radiogroup" aria-label="Sharing method">
4649
+ <button type="button" id="share-option-random" class="option-card" role="radio" aria-checked="false">
4650
+ <span class="option-dot" aria-hidden="true"></span>
4651
+ <span class="option-body">
4652
+ <span class="option-title">Random URL</span>
4653
+ <span class="option-desc">https://&lt;random&gt;.trycloudflare.com &middot; No account needed. Changes each time sharing restarts.</span>
4654
+ </span>
4655
+ </button>
4656
+ <button type="button" id="share-option-custom" class="option-card" role="radio" aria-checked="false">
4657
+ <span class="option-dot" aria-hidden="true"></span>
4658
+ <span class="option-body">
4659
+ <span class="option-title">Custom domain</span>
4660
+ <span class="option-desc">Your own hostname on a domain in your Cloudflare account. Stable URL.</span>
4661
+ <span id="share-custom-extra" class="option-extra" hidden>
4662
+ <label for="share-domain-input" class="dim">Hostname</label>
4663
+ <input type="text" id="share-domain-input" class="input mono" placeholder="app.example.com" autocomplete="off">
4664
+ <div id="share-domain-error" class="field-error" role="alert" hidden></div>
4665
+ <p id="share-domain-hint" class="modal-hint"></p>
4666
+ </span>
4667
+ </span>
4668
+ </button>
4669
+ </div>
4670
+ <div id="share-progress" class="share-progress" hidden>
4671
+ <div id="share-progress-icon" class="share-progress-icon" aria-hidden="true"></div>
4672
+ <div class="share-progress-body">
4673
+ <p id="share-progress-title" class="share-progress-title"></p>
4674
+ <p id="share-progress-text" class="share-progress-text"></p>
4675
+ <ol id="share-steps" class="share-steps" hidden>
4676
+ <li>Sign in to Cloudflare in the tab that opens.</li>
4677
+ <li>Pick the zone <span id="share-steps-domain" class="mono"></span> is in, then <strong>Authorize</strong>.</li>
4678
+ <li>Come back here. This dialog updates by itself.</li>
4679
+ </ol>
4680
+ <a id="share-auth-link" class="btn btn-primary" target="_blank" rel="noopener noreferrer" hidden>Open Cloudflare &#8599;</a>
4681
+ </div>
4682
+ </div>
4683
+ <div id="share-error" class="field-error" role="alert" hidden></div>
4684
+ </div>
4685
+ <div class="modal-foot">
4686
+ <button type="button" id="share-cancel" class="btn">Cancel</button>
4687
+ <button type="button" id="share-submit" class="btn btn-primary">Start sharing</button>
4688
+ </div>
4689
+ </dialog>
4690
+ <dialog id="protect-dialog">
4691
+ <div class="modal-head">
4692
+ <div>
4693
+ <h3 class="modal-title">Password protection</h3>
4694
+ <p id="protect-host" class="modal-sub mono"></p>
4695
+ </div>
4696
+ <button type="button" id="protect-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>
4697
+ </div>
4698
+ <div class="modal-body">
4699
+ <p class="modal-desc">Visitors see a sign-in page and need this password before they reach the app.</p>
4700
+ <div class="field">
4701
+ <label for="protect-password">Password</label>
4702
+ <div class="field-password">
4703
+ <input type="password" id="protect-password" class="input" autocomplete="new-password">
4704
+ <button type="button" id="protect-toggle" class="field-toggle" aria-label="Show password">
4705
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>
4706
+ </button>
4707
+ </div>
4708
+ <p id="protect-hint" class="modal-hint" hidden></p>
4709
+ <div id="protect-error" class="field-error" role="alert" hidden></div>
4710
+ </div>
4711
+ <div class="field">
4712
+ <label>Ask for it on</label>
4713
+ <div id="protect-options" class="share-options" role="group" aria-label="Ask for the password on">
4714
+ <button type="button" id="protect-option-shared" class="option-card" role="checkbox" aria-checked="false" data-scope="shared">
4715
+ <span class="option-check" aria-hidden="true"></span>
4716
+ <span class="option-body">
4717
+ <span class="option-title">Shared link</span>
4718
+ <span class="option-desc">Visitors on the public tunnel URL (Share).</span>
4719
+ </span>
4720
+ </button>
4721
+ <button type="button" id="protect-option-remote" class="option-card" role="checkbox" aria-checked="false" data-scope="remote">
4722
+ <span class="option-check" aria-hidden="true"></span>
4723
+ <span class="option-body">
4724
+ <span class="option-title">Remote access</span>
4725
+ <span class="option-desc">Machines connected to this one through remote access.</span>
4726
+ </span>
4727
+ </button>
4728
+ <button type="button" id="protect-option-local" class="option-card" role="checkbox" aria-checked="false" data-scope="local">
4729
+ <span class="option-check" aria-hidden="true"></span>
4730
+ <span class="option-body">
4731
+ <span class="option-title">This machine &amp; network</span>
4732
+ <span class="option-desc">Browsers on this computer or your local network.</span>
4733
+ </span>
4734
+ </button>
4735
+ </div>
4736
+ <div id="protect-scope-error" class="field-error" role="alert" hidden></div>
4737
+ </div>
4738
+ </div>
4739
+ <div class="modal-foot">
4740
+ <button type="button" id="protect-remove" class="btn btn-danger" hidden>Remove protection</button>
4741
+ <button type="button" id="protect-cancel" class="btn">Cancel</button>
4742
+ <button type="button" id="protect-submit" class="btn btn-primary">Protect host</button>
4743
+ </div>
4744
+ </dialog>
3652
4745
  <div id="toast-container" class="toast-container" aria-live="polite"></div>
3653
4746
  <script nonce="${safeNonce}">${clientJs}</script>
3654
4747
  </body>