fluxy-bot 0.7.8 → 0.8.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.
@@ -1 +1 @@
1
- import{e as o,j as e,R as n,O as r}from"./globals-CMrTFJSE.js";function a(){const t=()=>{window.parent?.postMessage({type:"fluxy:onboard-complete"},"*")};return e.jsx(r,{onComplete:t,isInitialSetup:!0})}o.createRoot(document.getElementById("root")).render(e.jsx(n.StrictMode,{children:e.jsx(a,{})}));
1
+ import{e as o,j as e,R as n,O as r}from"./globals-C5RAI3N1.js";function a(){const t=()=>{window.parent?.postMessage({type:"fluxy:onboard-complete"},"*")};return e.jsx(r,{onComplete:t,isInitialSetup:!0})}o.createRoot(document.getElementById("root")).render(e.jsx(n.StrictMode,{children:e.jsx(a,{})}));
@@ -4,9 +4,9 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, interactive-widget=resizes-content" />
6
6
  <title>Fluxy Chat</title>
7
- <script type="module" crossorigin src="/fluxy/assets/fluxy-Bcd5tJrt.js"></script>
8
- <link rel="modulepreload" crossorigin href="/fluxy/assets/globals-CMrTFJSE.js">
9
- <link rel="stylesheet" crossorigin href="/fluxy/assets/globals-Bs_wR6rP.css">
7
+ <script type="module" crossorigin src="/fluxy/assets/fluxy-BL_LI7ag.js"></script>
8
+ <link rel="modulepreload" crossorigin href="/fluxy/assets/globals-C5RAI3N1.js">
9
+ <link rel="stylesheet" crossorigin href="/fluxy/assets/globals-BFIvNq55.css">
10
10
  </head>
11
11
  <body class="bg-background text-foreground">
12
12
  <div id="root"></div>
@@ -4,9 +4,9 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, interactive-widget=resizes-content" />
6
6
  <title>Fluxy Setup</title>
7
- <script type="module" crossorigin src="/fluxy/assets/onboard-BSlNrxVH.js"></script>
8
- <link rel="modulepreload" crossorigin href="/fluxy/assets/globals-CMrTFJSE.js">
9
- <link rel="stylesheet" crossorigin href="/fluxy/assets/globals-Bs_wR6rP.css">
7
+ <script type="module" crossorigin src="/fluxy/assets/onboard-B5MmiDn8.js"></script>
8
+ <link rel="modulepreload" crossorigin href="/fluxy/assets/globals-C5RAI3N1.js">
9
+ <link rel="stylesheet" crossorigin href="/fluxy/assets/globals-BFIvNq55.css">
10
10
  </head>
11
11
  <body class="bg-background text-foreground">
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fluxy-bot",
3
- "version": "0.7.8",
3
+ "version": "0.8.0",
4
4
  "releaseNotes": [
5
5
  "Fixed some bugs to iOs ",
6
6
  "2. ",
package/shared/relay.ts CHANGED
@@ -36,6 +36,27 @@ export async function checkAvailability(
36
36
  return res.json();
37
37
  }
38
38
 
39
+ // ─── Claim a reserved (purchased) handle ────────────────────────────────────
40
+
41
+ export async function claimReservedHandle(
42
+ handle: string,
43
+ hash: string,
44
+ ): Promise<{ token: string; relayUrl: string }> {
45
+ const res = await fetch(`${RELAY_API}/handle/claim-reserved`, {
46
+ method: 'POST',
47
+ headers: { 'Content-Type': 'application/json' },
48
+ body: JSON.stringify({ handle, hash }),
49
+ });
50
+
51
+ const data = await res.json();
52
+
53
+ if (!res.ok) {
54
+ throw new Error(data.error || `Claim failed (${res.status})`);
55
+ }
56
+
57
+ return { token: data.token, relayUrl: data.relayUrl };
58
+ }
59
+
39
60
  // ─── Release handle ─────────────────────────────────────────────────────────
40
61
 
41
62
  export async function releaseHandle(token: string): Promise<void> {
@@ -1,7 +1,47 @@
1
1
  import { useState, useRef, useEffect, type KeyboardEvent } from 'react';
2
- import { ArrowRight, ArrowLeft, LoaderCircle, ExternalLink, ClipboardPaste, RefreshCw, Check, ChevronDown, Mic, Eye, EyeOff, Shield, ShieldCheck, ShieldOff, Copy, Smartphone } from 'lucide-react';
2
+ import { ArrowRight, ArrowLeft, LoaderCircle, ExternalLink, ClipboardPaste, RefreshCw, Check, ChevronDown, Mic, Eye, EyeOff, Shield, ShieldCheck, ShieldOff, Copy, Smartphone, Globe, Wifi, WifiOff, AlertTriangle } from 'lucide-react';
3
3
  import { motion, AnimatePresence } from 'framer-motion';
4
4
 
5
+ /* ── Access detection ── */
6
+
7
+ type AccessMethod = 'tailscale' | 'lan' | 'localhost' | 'tunnel' | 'relay' | 'custom-domain';
8
+
9
+ function detectAccessMethod(hostname: string): AccessMethod {
10
+ // Tailscale CGNAT range: 100.64.0.0 – 100.127.255.255
11
+ const tailscaleMatch = hostname.match(/^100\.(\d+)\./);
12
+ if (tailscaleMatch && +tailscaleMatch[1] >= 64 && +tailscaleMatch[1] <= 127) return 'tailscale';
13
+
14
+ // LAN ranges
15
+ if (/^192\.168\./.test(hostname) || /^10\./.test(hostname)) return 'lan';
16
+ const m172 = hostname.match(/^172\.(\d+)\./);
17
+ if (m172 && +m172[1] >= 16 && +m172[1] <= 31) return 'lan';
18
+
19
+ // Localhost
20
+ if (hostname === 'localhost' || hostname === '127.0.0.1') return 'localhost';
21
+
22
+ // Cloudflare quick tunnel
23
+ if (hostname.endsWith('.trycloudflare.com')) return 'tunnel';
24
+
25
+ // Relay domain
26
+ if (hostname.endsWith('.fluxy.bot')) return 'relay';
27
+
28
+ // Anything else is a custom domain (named tunnel)
29
+ return 'custom-domain';
30
+ }
31
+
32
+ function isPrivateAccess(method: AccessMethod): boolean {
33
+ return method === 'tailscale' || method === 'lan' || method === 'localhost';
34
+ }
35
+
36
+ const ACCESS_LABELS: Record<AccessMethod, string> = {
37
+ tailscale: 'Tailscale',
38
+ lan: 'Local network',
39
+ localhost: 'Localhost',
40
+ tunnel: 'Cloudflare tunnel',
41
+ relay: 'Relay',
42
+ 'custom-domain': 'Custom domain',
43
+ };
44
+
5
45
  /* ── Provider config ── */
6
46
 
7
47
  const PROVIDERS = [
@@ -88,9 +128,10 @@ interface Props {
88
128
  onComplete: () => void;
89
129
  isInitialSetup?: boolean;
90
130
  onSave?: (payload: any) => Promise<any>;
131
+ onTunnelSwitch?: (newMode: 'off' | 'quick') => Promise<any>;
91
132
  }
92
133
 
93
- export default function OnboardWizard({ onComplete, isInitialSetup = false, onSave }: Props) {
134
+ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSave, onTunnelSwitch }: Props) {
94
135
  const TOTAL_STEPS = isInitialSetup ? 7 : 6; // 0..5 normal, +step 6 "All Set" for initial
95
136
 
96
137
  const [step, setStep] = useState(0);
@@ -138,6 +179,23 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
138
179
  const [showChangeConfirm, setShowChangeConfirm] = useState(false);
139
180
  const [changingHandle, setChangingHandle] = useState(false);
140
181
 
182
+ // Reserved handle claim flow
183
+ const [tierReserved, setTierReserved] = useState<Record<string, boolean>>({});
184
+ const [showClaimInput, setShowClaimInput] = useState(false);
185
+ const [claimCode, setClaimCode] = useState('');
186
+ const [claimError, setClaimError] = useState('');
187
+ const [claiming, setClaiming] = useState(false);
188
+
189
+ // Access detection + Tailscale-only switch
190
+ const [accessMethod, setAccessMethod] = useState<AccessMethod>('tunnel');
191
+ const [showTailscaleSwitch, setShowTailscaleSwitch] = useState(false);
192
+ const [tailscaleConfirmText, setTailscaleConfirmText] = useState('');
193
+ const [tailscaleSwitching, setTailscaleSwitching] = useState(false);
194
+ const [tailscaleSwitchError, setTailscaleSwitchError] = useState('');
195
+ const [showReEnableTunnel, setShowReEnableTunnel] = useState(false);
196
+ const [reEnabling, setReEnabling] = useState(false);
197
+ const [reEnableError, setReEnableError] = useState('');
198
+
141
199
  // Portal credentials (step 3)
142
200
  const [portalUser, setPortalUser] = useState('admin');
143
201
  const [portalPass, setPortalPass] = useState('');
@@ -250,6 +308,11 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
250
308
  .catch(() => { prefillDone.current = true; });
251
309
  }, []);
252
310
 
311
+ // Detect access method on mount
312
+ useEffect(() => {
313
+ setAccessMethod(detectAccessMethod(window.location.hostname));
314
+ }, []);
315
+
253
316
  // Mobile detection for TOTP setup
254
317
  useEffect(() => {
255
318
  setIsMobileDevice(window.matchMedia('(max-width: 768px)').matches || 'ontouchstart' in window);
@@ -312,6 +375,10 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
312
375
  setHandleStatus(null);
313
376
  setHandleError('');
314
377
  setTierAvailability({});
378
+ setTierReserved({});
379
+ setShowClaimInput(false);
380
+ setClaimCode('');
381
+ setClaimError('');
315
382
  setRegistered(false);
316
383
  setRegisteredUrl('');
317
384
 
@@ -333,10 +400,13 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
333
400
  setHandleError(data.error);
334
401
  } else {
335
402
  const avail: Record<string, boolean> = {};
403
+ const reserved: Record<string, boolean> = {};
336
404
  for (const h of data.handles) {
337
405
  avail[h.tier] = h.available;
406
+ if (h.reserved) reserved[h.tier] = true;
338
407
  }
339
408
  setTierAvailability(avail);
409
+ setTierReserved(reserved);
340
410
  setHandleStatus('ready');
341
411
  // Auto-select first available tier
342
412
  const firstAvailable = HANDLES.find((h) => avail[h.tier]);
@@ -408,6 +478,34 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
408
478
  }
409
479
  };
410
480
 
481
+ const onClaimReserved = async () => {
482
+ if (!botName || !claimCode) return;
483
+ setClaiming(true);
484
+ setClaimError('');
485
+ try {
486
+ const res = await fetch('/api/handle/claim-reserved', {
487
+ method: 'POST',
488
+ headers: { 'Content-Type': 'application/json' },
489
+ body: JSON.stringify({ handle: botName, hash: claimCode }),
490
+ });
491
+ const data = await res.json();
492
+ if (data.ok) {
493
+ setRegistered(true);
494
+ setRegisteredUrl(data.url);
495
+ setShowClaimInput(false);
496
+ setClaimCode('');
497
+ setSelectedTier('premium');
498
+ setHandleChoice('relay');
499
+ } else {
500
+ setClaimError(data.error || 'Invalid activation code');
501
+ }
502
+ } catch {
503
+ setClaimError('Could not reach server');
504
+ } finally {
505
+ setClaiming(false);
506
+ }
507
+ };
508
+
411
509
  const handleProviderChange = (id: string) => {
412
510
  if (provider === 'openai' && id !== 'openai' && openaiWaiting) {
413
511
  fetch('/api/auth/codex/cancel', { method: 'POST' });
@@ -543,6 +641,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
543
641
  case 0: return true;
544
642
  case 1: return userName.trim().length > 0;
545
643
  case 2: {
644
+ if (showTailscaleSwitch || showReEnableTunnel) return false;
546
645
  if (tunnelMode === 'off') return botName.trim().length >= 3;
547
646
  if (tunnelMode === 'named') return botName.trim().length >= 3;
548
647
  if (handleChoice === 'tunnel') return botName.trim().length >= 3;
@@ -702,7 +801,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
702
801
  {step === 2 && tunnelMode === 'off' && (
703
802
  <div>
704
803
  <h1 className="text-xl font-bold text-white tracking-tight">
705
- Name your bot
804
+ Bot Name & Access
706
805
  </h1>
707
806
  <p className="text-white/40 text-[13px] mt-1.5 leading-relaxed">
708
807
  Give your bot a name. This is used throughout the app as your bot's identity.
@@ -729,6 +828,84 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
729
828
  </p>
730
829
  </div>
731
830
 
831
+ {/* Access badge */}
832
+ {!isInitialSetup && (
833
+ <div className="mt-3 flex items-center gap-2">
834
+ <span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium border ${
835
+ isPrivateAccess(accessMethod)
836
+ ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20'
837
+ : 'bg-blue-500/10 text-blue-400 border-blue-500/20'
838
+ }`}>
839
+ {isPrivateAccess(accessMethod) ? <WifiOff className="h-3 w-3" /> : <Globe className="h-3 w-3" />}
840
+ Accessing via {ACCESS_LABELS[accessMethod]}
841
+ </span>
842
+ </div>
843
+ )}
844
+
845
+ {/* Re-enable tunnel option */}
846
+ {!isInitialSetup && !showReEnableTunnel && (
847
+ <button
848
+ onClick={() => { setShowReEnableTunnel(true); setReEnableError(''); }}
849
+ className="w-full mt-4 flex items-center justify-between px-4 py-3 rounded-xl border border-white/[0.06] bg-white/[0.02] hover:border-white/10 hover:bg-white/[0.04] transition-all text-left"
850
+ >
851
+ <div>
852
+ <p className="text-[13px] text-white/70 font-medium">Re-enable public tunnel access</p>
853
+ <p className="text-[11px] text-white/30 mt-0.5">Start a Cloudflare tunnel to make your bot accessible from anywhere</p>
854
+ </div>
855
+ <Globe className="h-4 w-4 text-white/30 shrink-0 ml-3" />
856
+ </button>
857
+ )}
858
+
859
+ {/* Re-enable confirmation */}
860
+ {showReEnableTunnel && (
861
+ <div className="mt-4 space-y-3">
862
+ <div className="bg-amber-500/8 border border-amber-500/20 rounded-xl px-4 py-3">
863
+ <div className="flex items-start gap-2">
864
+ <AlertTriangle className="h-4 w-4 text-amber-400 shrink-0 mt-0.5" />
865
+ <div>
866
+ <p className="text-amber-400/90 text-[13px] font-medium">Enable public access?</p>
867
+ <p className="text-amber-400/60 text-[12px] mt-1 leading-relaxed">
868
+ This will start a Cloudflare tunnel, making your bot reachable from the internet.
869
+ {existingHandle && ' Your handle will reconnect automatically.'}
870
+ </p>
871
+ </div>
872
+ </div>
873
+ </div>
874
+ {reEnableError && (
875
+ <p className="text-red-400/70 text-[12px]">{reEnableError}</p>
876
+ )}
877
+ <div className="flex gap-2">
878
+ <button
879
+ onClick={async () => {
880
+ if (!onTunnelSwitch) return;
881
+ setReEnabling(true);
882
+ setReEnableError('');
883
+ try {
884
+ await onTunnelSwitch('quick');
885
+ setTunnelMode('quick');
886
+ setShowReEnableTunnel(false);
887
+ } catch (err: any) {
888
+ setReEnableError(err.message || 'Failed to start tunnel');
889
+ } finally {
890
+ setReEnabling(false);
891
+ }
892
+ }}
893
+ disabled={reEnabling || !onTunnelSwitch}
894
+ className="flex-1 py-2.5 bg-gradient-brand hover:opacity-90 text-white text-[13px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
895
+ >
896
+ {reEnabling ? <><LoaderCircle className="h-4 w-4 animate-spin" />Starting tunnel...</> : 'Enable Tunnel'}
897
+ </button>
898
+ <button
899
+ onClick={() => { setShowReEnableTunnel(false); setReEnableError(''); }}
900
+ disabled={reEnabling}
901
+ className="px-5 py-2.5 bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] text-white/60 text-[13px] font-medium rounded-full transition-colors"
902
+ >
903
+ Cancel
904
+ </button>
905
+ </div>
906
+ </div>
907
+ )}
908
+
732
909
  <button
733
910
  onClick={next}
734
911
  disabled={!canNext}
@@ -743,7 +920,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
743
920
  {step === 2 && tunnelMode === 'named' && (
744
921
  <div>
745
922
  <h1 className="text-xl font-bold text-white tracking-tight">
746
- Name your bot
923
+ Bot Name & Access
747
924
  </h1>
748
925
  <p className="text-white/40 text-[13px] mt-1.5 leading-relaxed">
749
926
  This is your bot's identity. Your named tunnel domain is already configured.
@@ -787,7 +964,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
787
964
  {step === 2 && tunnelMode === 'quick' && (
788
965
  <div>
789
966
  <h1 className="text-xl font-bold text-white tracking-tight">
790
- Name your bot
967
+ Bot Name & Access
791
968
  </h1>
792
969
  <p className="text-white/40 text-[13px] mt-1.5 leading-relaxed">
793
970
  This is your bot's name and permanent handle — access it from anywhere.
@@ -894,36 +1071,80 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
894
1071
  {HANDLES.map((h) => {
895
1072
  const available = tierAvailability[h.tier];
896
1073
  const taken = available === false;
1074
+ const isReserved = taken && tierReserved[h.tier];
897
1075
  return (
898
- <button
899
- key={h.tier}
900
- onClick={() => { if (!taken) { setSelectedTier(h.tier); setHandleChoice('relay'); } }}
901
- disabled={taken}
902
- className={`w-full flex items-center justify-between px-4 py-3 rounded-xl border transition-all duration-200 text-left ${
903
- taken
904
- ? 'border-white/[0.04] bg-transparent opacity-50 cursor-not-allowed'
905
- : handleChoice === 'relay' && selectedTier === h.tier
906
- ? h.highlight
907
- ? 'border-[#AF27E3]/40 bg-[#AF27E3]/[0.06]'
908
- : 'border-[#AF27E3]/30 bg-white/[0.04]'
909
- : 'border-white/[0.06] bg-transparent hover:border-white/10 hover:bg-white/[0.02]'
910
- }`}
911
- >
912
- <span className={`font-mono text-[13px] ${
913
- taken ? 'text-white/40' : handleChoice === 'relay' && selectedTier === h.tier ? 'text-white/80' : 'text-white/50'
914
- }`}>
915
- {h.label(botName)}
916
- </span>
917
- {taken ? (
918
- <span className="text-[11px] font-medium px-2.5 py-0.5 rounded-full border bg-red-500/10 text-red-400 border-red-500/20">
919
- Taken
920
- </span>
921
- ) : (
922
- <span className={`text-[11px] font-medium px-2.5 py-0.5 rounded-full border ${h.badgeCls}`}>
923
- {h.badge}
1076
+ <div key={h.tier}>
1077
+ <button
1078
+ onClick={() => { if (!taken) { setSelectedTier(h.tier); setHandleChoice('relay'); } }}
1079
+ disabled={taken && !isReserved}
1080
+ className={`w-full flex items-center justify-between px-4 py-3 rounded-xl border transition-all duration-200 text-left ${
1081
+ taken
1082
+ ? 'border-white/[0.04] bg-transparent opacity-50 cursor-not-allowed'
1083
+ : handleChoice === 'relay' && selectedTier === h.tier
1084
+ ? h.highlight
1085
+ ? 'border-[#AF27E3]/40 bg-[#AF27E3]/[0.06]'
1086
+ : 'border-[#AF27E3]/30 bg-white/[0.04]'
1087
+ : 'border-white/[0.06] bg-transparent hover:border-white/10 hover:bg-white/[0.02]'
1088
+ }`}
1089
+ >
1090
+ <span className={`font-mono text-[13px] ${
1091
+ taken ? 'text-white/40' : handleChoice === 'relay' && selectedTier === h.tier ? 'text-white/80' : 'text-white/50'
1092
+ }`}>
1093
+ {h.label(botName)}
924
1094
  </span>
1095
+ {taken ? (
1096
+ <span className="text-[11px] font-medium px-2.5 py-0.5 rounded-full border bg-red-500/10 text-red-400 border-red-500/20">
1097
+ Taken
1098
+ </span>
1099
+ ) : (
1100
+ <span className={`text-[11px] font-medium px-2.5 py-0.5 rounded-full border ${h.badgeCls}`}>
1101
+ {h.badge}
1102
+ </span>
1103
+ )}
1104
+ </button>
1105
+ {/* "I own this handle" — only for reserved (purchased) handles */}
1106
+ {isReserved && !showClaimInput && (
1107
+ <button
1108
+ onClick={() => { setShowClaimInput(true); setClaimCode(''); setClaimError(''); }}
1109
+ className="mt-1.5 ml-1 text-[12px] text-[#AF27E3]/80 hover:text-[#AF27E3] transition-colors"
1110
+ >
1111
+ I own this handle
1112
+ </button>
925
1113
  )}
926
- </button>
1114
+ {/* Inline activation code input */}
1115
+ {isReserved && showClaimInput && (
1116
+ <div className="mt-2 space-y-2">
1117
+ <div className="flex gap-2">
1118
+ <input
1119
+ type="text"
1120
+ value={claimCode}
1121
+ onChange={(e) => setClaimCode(e.target.value.trim())}
1122
+ maxLength={5}
1123
+ placeholder="Activation code"
1124
+ spellCheck={false}
1125
+ autoFocus
1126
+ className="flex-1 bg-white/[0.03] border border-white/[0.08] text-white rounded-lg px-3 py-2 text-[13px] font-mono outline-none focus:border-[#AF27E3]/30 transition-colors placeholder:text-white/20"
1127
+ />
1128
+ <button
1129
+ onClick={onClaimReserved}
1130
+ disabled={claiming || claimCode.length < 5}
1131
+ className="px-4 py-2 bg-gradient-brand hover:opacity-90 text-white text-[13px] font-semibold rounded-lg transition-colors flex items-center gap-1.5 disabled:opacity-40"
1132
+ >
1133
+ {claiming ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> : 'Activate'}
1134
+ </button>
1135
+ </div>
1136
+ {claimError && (
1137
+ <p className="text-red-400 text-[12px]">{claimError}</p>
1138
+ )}
1139
+ <button
1140
+ onClick={() => { setShowClaimInput(false); setClaimCode(''); setClaimError(''); }}
1141
+ className="text-[11px] text-white/25 hover:text-white/40 transition-colors"
1142
+ >
1143
+ Cancel
1144
+ </button>
1145
+ </div>
1146
+ )}
1147
+ </div>
927
1148
  );
928
1149
  })}
929
1150
 
@@ -1013,6 +1234,120 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1013
1234
  )}
1014
1235
  </>
1015
1236
  )}
1237
+
1238
+ {/* ── Tailscale-only switch (only when re-running wizard) ── */}
1239
+ {!isInitialSetup && (
1240
+ <>
1241
+ {/* Access badge */}
1242
+ <div className="mt-5 flex items-center gap-2">
1243
+ <span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium border ${
1244
+ isPrivateAccess(accessMethod)
1245
+ ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20'
1246
+ : 'bg-blue-500/10 text-blue-400 border-blue-500/20'
1247
+ }`}>
1248
+ {isPrivateAccess(accessMethod) ? <Wifi className="h-3 w-3" /> : <Globe className="h-3 w-3" />}
1249
+ Accessing via {ACCESS_LABELS[accessMethod]}
1250
+ </span>
1251
+ </div>
1252
+
1253
+ {/* Switch to private network only */}
1254
+ {!showTailscaleSwitch && (
1255
+ <button
1256
+ onClick={() => {
1257
+ if (isPrivateAccess(accessMethod)) {
1258
+ setShowTailscaleSwitch(true);
1259
+ setTailscaleConfirmText('');
1260
+ setTailscaleSwitchError('');
1261
+ }
1262
+ }}
1263
+ disabled={!isPrivateAccess(accessMethod)}
1264
+ className={`w-full mt-3 flex items-center justify-between px-4 py-3 rounded-xl border transition-all text-left ${
1265
+ isPrivateAccess(accessMethod)
1266
+ ? 'border-white/[0.06] bg-white/[0.02] hover:border-white/10 hover:bg-white/[0.04] cursor-pointer'
1267
+ : 'border-white/[0.04] bg-transparent opacity-50 cursor-not-allowed'
1268
+ }`}
1269
+ >
1270
+ <div>
1271
+ <p className="text-[13px] text-white/70 font-medium">Switch to private network only</p>
1272
+ <p className="text-[11px] text-white/30 mt-0.5">
1273
+ {isPrivateAccess(accessMethod)
1274
+ ? 'Stop the tunnel and relay — access only via Tailscale or LAN'
1275
+ : 'Connect via Tailscale or private network to unlock'}
1276
+ </p>
1277
+ </div>
1278
+ <WifiOff className="h-4 w-4 text-white/30 shrink-0 ml-3" />
1279
+ </button>
1280
+ )}
1281
+
1282
+ {/* Confirmation flow */}
1283
+ {showTailscaleSwitch && (
1284
+ <div className="mt-3 space-y-3">
1285
+ <div className="bg-amber-500/8 border border-amber-500/20 rounded-xl px-4 py-3">
1286
+ <div className="flex items-start gap-2">
1287
+ <AlertTriangle className="h-4 w-4 text-amber-400 shrink-0 mt-0.5" />
1288
+ <div>
1289
+ <p className="text-amber-400/90 text-[13px] font-medium">Switch to private network only?</p>
1290
+ <p className="text-amber-400/60 text-[12px] mt-1 leading-relaxed">
1291
+ This will stop the Cloudflare tunnel and relay connection. Your bot will only be accessible via your private network.
1292
+ </p>
1293
+ {existingHandle && (
1294
+ <p className="text-amber-400/50 text-[12px] mt-1.5">
1295
+ Your handle will be preserved and can be re-activated later.
1296
+ </p>
1297
+ )}
1298
+ </div>
1299
+ </div>
1300
+ </div>
1301
+ <div>
1302
+ <label className="text-[12px] text-white/40 font-medium mb-1.5 block">
1303
+ Type <span className="font-mono text-white/60">I confirm</span> to proceed
1304
+ </label>
1305
+ <input
1306
+ type="text"
1307
+ value={tailscaleConfirmText}
1308
+ onChange={(e) => setTailscaleConfirmText(e.target.value)}
1309
+ placeholder="I confirm"
1310
+ spellCheck={false}
1311
+ autoFocus
1312
+ className={inputSmCls}
1313
+ />
1314
+ </div>
1315
+ {tailscaleSwitchError && (
1316
+ <p className="text-red-400/70 text-[12px]">{tailscaleSwitchError}</p>
1317
+ )}
1318
+ <div className="flex gap-2">
1319
+ <button
1320
+ onClick={async () => {
1321
+ if (!onTunnelSwitch || tailscaleConfirmText.trim().toLowerCase() !== 'i confirm') return;
1322
+ setTailscaleSwitching(true);
1323
+ setTailscaleSwitchError('');
1324
+ try {
1325
+ await onTunnelSwitch('off');
1326
+ setTunnelMode('off');
1327
+ setShowTailscaleSwitch(false);
1328
+ } catch (err: any) {
1329
+ setTailscaleSwitchError(err.message || 'Failed to switch');
1330
+ } finally {
1331
+ setTailscaleSwitching(false);
1332
+ }
1333
+ }}
1334
+ disabled={tailscaleSwitching || tailscaleConfirmText.trim().toLowerCase() !== 'i confirm' || !onTunnelSwitch}
1335
+ className="flex-1 py-2.5 bg-amber-600 hover:bg-amber-500 text-white text-[13px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
1336
+ >
1337
+ {tailscaleSwitching ? <><LoaderCircle className="h-4 w-4 animate-spin" />Switching...</> : 'Confirm Switch'}
1338
+ </button>
1339
+ <button
1340
+ onClick={() => { setShowTailscaleSwitch(false); setTailscaleConfirmText(''); setTailscaleSwitchError(''); }}
1341
+ disabled={tailscaleSwitching}
1342
+ className="px-5 py-2.5 bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] text-white/60 text-[13px] font-medium rounded-full transition-colors"
1343
+ >
1344
+ Cancel
1345
+ </button>
1346
+ </div>
1347
+ </div>
1348
+ )}
1349
+ </>
1350
+ )}
1016
1351
  </div>
1017
1352
  )}
1018
1353
 
@@ -402,6 +402,16 @@ function FluxyApp() {
402
402
  client.send('settings:save', payload);
403
403
  });
404
404
  }}
405
+ onTunnelSwitch={(newMode) => {
406
+ return new Promise((resolve, reject) => {
407
+ const client = clientRef.current;
408
+ if (!client?.connected) { reject(new Error('Not connected')); return; }
409
+ const unsub = client.on('tunnel:switched', (data) => { unsub(); unsubErr(); clearTimeout(t); resolve(data); });
410
+ const unsubErr = client.on('tunnel:switch-error', (data) => { unsub(); unsubErr(); clearTimeout(t); reject(new Error(data.error)); });
411
+ const t = setTimeout(() => { unsub(); unsubErr(); reject(new Error('Timeout')); }, 30000);
412
+ client.send('tunnel:switch', { mode: newMode });
413
+ });
414
+ }}
405
415
  onComplete={() => {
406
416
  setShowWizard(false);
407
417
  // Reload settings (bot name, whisper, etc.)