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.
- package/dist-fluxy/assets/{fluxy-Bcd5tJrt.js → fluxy-BL_LI7ag.js} +10 -10
- package/dist-fluxy/assets/globals-BFIvNq55.css +1 -0
- package/dist-fluxy/assets/globals-C5RAI3N1.js +18 -0
- package/dist-fluxy/assets/{onboard-BSlNrxVH.js → onboard-B5MmiDn8.js} +1 -1
- package/dist-fluxy/fluxy.html +3 -3
- package/dist-fluxy/onboard.html +3 -3
- package/package.json +1 -1
- package/shared/relay.ts +21 -0
- package/supervisor/chat/OnboardWizard.tsx +367 -32
- package/supervisor/chat/fluxy-main.tsx +10 -0
- package/supervisor/index.ts +101 -0
- package/worker/index.ts +46 -1
- package/dist-fluxy/assets/globals-Bs_wR6rP.css +0 -1
- package/dist-fluxy/assets/globals-CMrTFJSE.js +0 -18
|
@@ -1 +1 @@
|
|
|
1
|
-
import{e as o,j as e,R as n,O as r}from"./globals-
|
|
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,{})}));
|
package/dist-fluxy/fluxy.html
CHANGED
|
@@ -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-
|
|
8
|
-
<link rel="modulepreload" crossorigin href="/fluxy/assets/globals-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/fluxy/assets/globals-
|
|
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>
|
package/dist-fluxy/onboard.html
CHANGED
|
@@ -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-
|
|
8
|
-
<link rel="modulepreload" crossorigin href="/fluxy/assets/globals-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/fluxy/assets/globals-
|
|
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
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
|
|
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
|
|
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
|
|
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
|
-
<
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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
|
-
|
|
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.)
|