locadot 2.3.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.
@@ -812,8 +812,8 @@ details.card[open] summary { border-bottom: 1px solid var(--border); border-bott
812
812
  border-top: 1px solid var(--border);
813
813
  }
814
814
 
815
- /* ---------- confirm / prompt / share dialogs ---------- */
816
- #confirm-dialog, #prompt-dialog, #share-dialog {
815
+ /* ---------- confirm / prompt / share / protect dialogs ---------- */
816
+ #confirm-dialog, #prompt-dialog, #share-dialog, #protect-dialog {
817
817
  padding: 0;
818
818
  border: 1px solid var(--border);
819
819
  border-radius: var(--radius);
@@ -821,10 +821,54 @@ details.card[open] summary { border-bottom: 1px solid var(--border); border-bott
821
821
  color: var(--fg);
822
822
  box-shadow: var(--shadow-md);
823
823
  }
824
- #confirm-dialog::backdrop, #prompt-dialog::backdrop, #share-dialog::backdrop { background: rgba(0, 0, 0, 0.45); }
824
+ #confirm-dialog::backdrop, #prompt-dialog::backdrop, #share-dialog::backdrop, #protect-dialog::backdrop { background: rgba(0, 0, 0, 0.45); }
825
825
  #confirm-dialog { width: min(420px, calc(100vw - 32px)); }
826
826
  #prompt-dialog { width: min(420px, calc(100vw - 32px)); }
827
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; }
828
872
  .share-options { display: flex; flex-direction: column; gap: 10px; }
829
873
  .option-card {
830
874
  display: flex;
@@ -868,6 +912,15 @@ details.card[open] summary { border-bottom: 1px solid var(--border); border-bott
868
912
  .option-desc { font-size: 12.5px; color: var(--muted); line-height: 1.45; }
869
913
  .option-extra { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
870
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; }
871
924
 
872
925
  /* ---------- toasts ---------- */
873
926
  .toast-container {
@@ -1221,6 +1274,7 @@ const clientJs = `
1221
1274
  var cfInstallBusy = false;
1222
1275
  var cloudflaredInstalled = false;
1223
1276
  var cloudflareLoggedIn = false;
1277
+ var cloudflareZones = [];
1224
1278
  var proxyStopped = false;
1225
1279
  var pollTimer = null;
1226
1280
  var remotesPollTimer = null;
@@ -1439,6 +1493,7 @@ const clientJs = `
1439
1493
  var cf = sys.cloudflared || { installed: false };
1440
1494
  cloudflaredInstalled = !!cf.installed;
1441
1495
  cloudflareLoggedIn = !!cf.loggedIn;
1496
+ cloudflareZones = Array.isArray(cf.zones) ? cf.zones : [];
1442
1497
  if (cf.installed) {
1443
1498
  cfValueEl.textContent = "Installed";
1444
1499
  cfValueEl.className = "tile-value ok";
@@ -2385,6 +2440,18 @@ const clientJs = `
2385
2440
  var shareDomainError = document.getElementById("share-domain-error");
2386
2441
  var shareDomainHint = document.getElementById("share-domain-hint");
2387
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;
2388
2455
  var shareRow = null;
2389
2456
  var shareMode = "random";
2390
2457
  var HOSTNAME_RE = /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,}$/;
@@ -2406,9 +2473,7 @@ const clientJs = `
2406
2473
  shareOptionRandom.setAttribute("aria-checked", String(mode === "random"));
2407
2474
  shareOptionCustom.setAttribute("aria-checked", String(mode === "custom"));
2408
2475
  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.";
2476
+ updateDomainHint();
2412
2477
  shareDomainError.hidden = true;
2413
2478
  shareDomainError.textContent = "";
2414
2479
  shareErrorEl.hidden = true;
@@ -2416,7 +2481,108 @@ const clientJs = `
2416
2481
  if (mode === "custom") setTimeout(function () { shareDomainInput.focus(); }, 0);
2417
2482
  }
2418
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
+
2419
2583
  function closeShareDialog() {
2584
+ stopSharePoll();
2585
+ closeAuthWindow();
2420
2586
  shareRow = null;
2421
2587
  shareDialogEl.close();
2422
2588
  }
@@ -2430,6 +2596,9 @@ const clientJs = `
2430
2596
  shareDomainError.textContent = "";
2431
2597
  var hasDomain = typeof row.tunnelDomain === "string" && !!row.tunnelDomain;
2432
2598
  shareDomainInput.value = hasDomain ? row.tunnelDomain : "";
2599
+ showShareOptions();
2600
+ shareSubmitBtn.textContent = "Start sharing";
2601
+ shareAuthOpened = false;
2433
2602
  setShareMode(hasDomain ? "custom" : "random");
2434
2603
  shareSubmitBtn.disabled = false;
2435
2604
  shareSubmitBtn.classList.remove("busy");
@@ -2450,6 +2619,12 @@ const clientJs = `
2450
2619
  shareDialogEl.addEventListener("click", function (e) {
2451
2620
  if (e.target === shareDialogEl) closeShareDialog();
2452
2621
  });
2622
+ shareDialogEl.addEventListener("close", function () {
2623
+ stopSharePoll();
2624
+ closeAuthWindow();
2625
+ shareRow = null;
2626
+ });
2627
+ shareDomainInput.addEventListener("input", updateDomainHint);
2453
2628
  shareDomainInput.addEventListener("keydown", function (e) {
2454
2629
  if (e.key === "Enter") { e.preventDefault(); shareSubmitBtn.click(); }
2455
2630
  });
@@ -2470,13 +2645,32 @@ const clientJs = `
2470
2645
  shareSubmitBtn.disabled = true;
2471
2646
  shareSubmitBtn.classList.add("busy");
2472
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
+ }
2473
2662
  apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "PUT", body: JSON.stringify(body) })
2474
2663
  .then(function () {
2664
+ if (body.domain) {
2665
+ watchShare(row.host, body.domain);
2666
+ return loadHosts();
2667
+ }
2475
2668
  showToast("Starting tunnel for " + row.host, "success");
2476
2669
  closeShareDialog();
2477
2670
  return loadHosts();
2478
2671
  })
2479
2672
  .catch(function (err2) {
2673
+ closeAuthWindow();
2480
2674
  shareErrorEl.textContent = (err2 && err2.message) ? err2.message : String(err2);
2481
2675
  shareErrorEl.hidden = false;
2482
2676
  })
@@ -2616,6 +2810,167 @@ const clientJs = `
2616
2810
  });
2617
2811
  });
2618
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 }) })
2916
+ .then(function () {
2917
+ showToast("Removed password protection for " + row.host, "success");
2918
+ closeProtectDialog();
2919
+ return loadHosts();
2920
+ })
2921
+ .catch(function (err) {
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;
2927
+ });
2928
+ });
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
+
2619
2974
  // ---------- confirm / prompt dialogs ----------
2620
2975
 
2621
2976
  var confirmDialogEl = document.getElementById("confirm-dialog");
@@ -2765,6 +3120,7 @@ const clientJs = `
2765
3120
  if (row.insecure) flags.push(["insecure", "Upstream TLS certificate is not verified", "badge-warn"]);
2766
3121
  if (row.cors) flags.push(["cors", "Origin/Referer rewritten to the target; any origin may call this host", "badge-cors"]);
2767
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"]);
2768
3124
  if (flags.length) {
2769
3125
  optionsTd.className = "badges";
2770
3126
  flags.forEach(function (flag) {
@@ -2814,6 +3170,20 @@ const clientJs = `
2814
3170
  accessSvg.appendChild(accessPath);
2815
3171
  accessBtn.appendChild(accessSvg);
2816
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); });
2817
3187
  var editBtn = document.createElement("button");
2818
3188
  editBtn.type = "button";
2819
3189
  editBtn.className = "btn btn-sm";
@@ -2841,6 +3211,7 @@ const clientJs = `
2841
3211
  if (!row.remote) {
2842
3212
  actionsWrap.appendChild(shareButton(row));
2843
3213
  actionsWrap.appendChild(accessBtn);
3214
+ actionsWrap.appendChild(protectBtn);
2844
3215
  actionsWrap.appendChild(editBtn);
2845
3216
  }
2846
3217
  actionsWrap.appendChild(removeBtn);
@@ -4296,6 +4667,19 @@ function renderPage(nonce, token, uiAuth = false) {
4296
4667
  </span>
4297
4668
  </button>
4298
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>
4299
4683
  <div id="share-error" class="field-error" role="alert" hidden></div>
4300
4684
  </div>
4301
4685
  <div class="modal-foot">
@@ -4303,6 +4687,61 @@ function renderPage(nonce, token, uiAuth = false) {
4303
4687
  <button type="button" id="share-submit" class="btn btn-primary">Start sharing</button>
4304
4688
  </div>
4305
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>
4306
4745
  <div id="toast-container" class="toast-container" aria-live="polite"></div>
4307
4746
  <script nonce="${safeNonce}">${clientJs}</script>
4308
4747
  </body>
package/dist/index.js CHANGED
@@ -55,6 +55,17 @@ program
55
55
  .option("--rm", "Remove the given addresses instead of adding them")
56
56
  .option("--clear", "Remove every allowed address")
57
57
  .action(run((addresses, options) => commands_1.default.allow(addresses, options)));
58
+ program
59
+ .command("protect")
60
+ .description("Ask for a password before a domain opens: on the shared link, for connected machines and/or on this machine")
61
+ .addOption(hostOption())
62
+ .option("--shared", "Ask visitors of the shared (tunnel) link")
63
+ .option("--remote", "Ask machines connected through remote access")
64
+ .option("--local", "Ask on this machine and your network")
65
+ .option("--password <password>", "The password (prompted for when left out)")
66
+ .option("--stdin", "Read the password from stdin")
67
+ .option("--off", "Stop asking for a password and forget it")
68
+ .action(run((options) => commands_1.default.protect(options)));
58
69
  program
59
70
  .command("remove")
60
71
  .alias("rm")