fluxy-bot 0.1.10 → 0.1.12

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.
@@ -32,7 +32,7 @@ const MODELS: Record<string, { id: string; label: string }[]> = {
32
32
  ],
33
33
  };
34
34
 
35
- const TOTAL_STEPS = 5; // 0..4
35
+ const TOTAL_STEPS = 6; // 0..5
36
36
 
37
37
  const HANDLES = [
38
38
  { tier: 'premium', label: (n: string) => `${n}.fluxy.bot`, badge: '$5', badgeCls: 'bg-primary/15 text-primary border-primary/20', highlight: true },
@@ -125,19 +125,52 @@ export default function OnboardWizard({ onComplete }: Props) {
125
125
 
126
126
  // Bot name + Handle (step 2)
127
127
  const [botName, setBotName] = useState('');
128
- const [handleStatus, setHandleStatus] = useState<null | 'checking' | 'available' | 'taken' | 'invalid'>(null);
128
+ const [handleStatus, setHandleStatus] = useState<null | 'checking' | 'ready' | 'invalid'>(null);
129
129
  const [handleError, setHandleError] = useState('');
130
+ const [tierAvailability, setTierAvailability] = useState<Record<string, boolean>>({});
130
131
  const [selectedTier, setSelectedTier] = useState('at');
131
132
  const [registering, setRegistering] = useState(false);
132
133
  const [registered, setRegistered] = useState(false);
133
134
  const [registeredUrl, setRegisteredUrl] = useState('');
134
135
  const handleDebounce = useRef<ReturnType<typeof setTimeout> | null>(null);
135
136
 
136
- // Whisper (step 4)
137
+ // Existing handle (for re-run / change flow)
138
+ const [existingHandle, setExistingHandle] = useState<{ username: string; tier: string; url: string } | null>(null);
139
+ const [showChangeConfirm, setShowChangeConfirm] = useState(false);
140
+ const [changingHandle, setChangingHandle] = useState(false);
141
+
142
+ // Portal credentials (step 3)
143
+ const [portalUser, setPortalUser] = useState('');
144
+ const [portalPass, setPortalPass] = useState('');
145
+ const [portalPassConfirm, setPortalPassConfirm] = useState('');
146
+ const [portalCopied, setPortalCopied] = useState(false);
147
+ const [portalExists, setPortalExists] = useState(false);
148
+
149
+ // Whisper (step 5)
137
150
  const [whisperEnabled, setWhisperEnabled] = useState(false);
151
+ const [whisperKey, setWhisperKey] = useState('');
138
152
 
139
153
  const isConnected = authState[provider] === 'connected';
140
154
 
155
+ // Pre-fill from existing settings (re-run wizard)
156
+ useEffect(() => {
157
+ Promise.all([
158
+ fetch('/api/settings').then((r) => r.json()),
159
+ fetch('/api/handle/status').then((r) => r.json()),
160
+ ]).then(([settings, handle]) => {
161
+ if (settings.user_name) setUserName(settings.user_name);
162
+ if (settings.portal_user) { setPortalUser(settings.portal_user); setPortalExists(true); }
163
+ if (settings.whisper_key) { setWhisperEnabled(true); setWhisperKey(settings.whisper_key); }
164
+ if (handle.registered && handle.username) {
165
+ setBotName(handle.username);
166
+ setSelectedTier(handle.tier || 'at');
167
+ setExistingHandle({ username: handle.username, tier: handle.tier, url: handle.url });
168
+ setRegistered(true);
169
+ setRegisteredUrl(handle.url);
170
+ }
171
+ }).catch(() => {});
172
+ }, []);
173
+
141
174
  // Check if Claude is already authenticated when selecting Anthropic
142
175
  useEffect(() => {
143
176
  if (provider !== 'anthropic' || authState.anthropic === 'connected') return;
@@ -176,11 +209,12 @@ export default function OnboardWizard({ onComplete }: Props) {
176
209
  return () => clearInterval(interval);
177
210
  }, [openaiWaiting]);
178
211
 
179
- // Handle availability check (debounced)
212
+ // Handle availability check (debounced, per-tier)
180
213
  useEffect(() => {
181
214
  if (handleDebounce.current) clearTimeout(handleDebounce.current);
182
215
  setHandleStatus(null);
183
216
  setHandleError('');
217
+ setTierAvailability({});
184
218
  setRegistered(false);
185
219
  setRegisteredUrl('');
186
220
 
@@ -200,10 +234,18 @@ export default function OnboardWizard({ onComplete }: Props) {
200
234
  if (!data.valid) {
201
235
  setHandleStatus('invalid');
202
236
  setHandleError(data.error);
203
- } else if (data.available) {
204
- setHandleStatus('available');
205
237
  } else {
206
- setHandleStatus('taken');
238
+ const avail: Record<string, boolean> = {};
239
+ for (const h of data.handles) {
240
+ avail[h.tier] = h.available;
241
+ }
242
+ setTierAvailability(avail);
243
+ setHandleStatus('ready');
244
+ // Auto-select first available tier
245
+ const firstAvailable = HANDLES.find((h) => avail[h.tier]);
246
+ if (firstAvailable && !avail[selectedTier]) {
247
+ setSelectedTier(firstAvailable.tier);
248
+ }
207
249
  }
208
250
  } catch {
209
251
  setHandleStatus(null);
@@ -218,7 +260,7 @@ export default function OnboardWizard({ onComplete }: Props) {
218
260
  };
219
261
 
220
262
  const onClaimHandle = async () => {
221
- if (!botName || handleStatus !== 'available') return;
263
+ if (!botName || handleStatus !== 'ready' || !tierAvailability[selectedTier]) return;
222
264
  setRegistering(true);
223
265
  try {
224
266
  const res = await fetch('/api/handle/register', {
@@ -242,6 +284,33 @@ export default function OnboardWizard({ onComplete }: Props) {
242
284
  }
243
285
  };
244
286
 
287
+ const onChangeHandle = async () => {
288
+ if (!botName || handleStatus !== 'ready' || !tierAvailability[selectedTier]) return;
289
+ setChangingHandle(true);
290
+ try {
291
+ const res = await fetch('/api/handle/change', {
292
+ method: 'POST',
293
+ headers: { 'Content-Type': 'application/json' },
294
+ body: JSON.stringify({ username: botName, tier: selectedTier }),
295
+ });
296
+ const data = await res.json();
297
+ if (data.ok) {
298
+ setRegistered(true);
299
+ setRegisteredUrl(data.url);
300
+ setExistingHandle({ username: botName, tier: selectedTier, url: data.url });
301
+ setShowChangeConfirm(false);
302
+ } else {
303
+ setHandleError(data.error || 'Handle change failed');
304
+ setHandleStatus('invalid');
305
+ }
306
+ } catch {
307
+ setHandleError('Could not reach server');
308
+ setHandleStatus('invalid');
309
+ } finally {
310
+ setChangingHandle(false);
311
+ }
312
+ };
313
+
245
314
  const handleProviderChange = (id: string) => {
246
315
  if (provider === 'openai' && id !== 'openai' && openaiWaiting) {
247
316
  fetch('/api/auth/codex/cancel', { method: 'POST' });
@@ -347,14 +416,19 @@ export default function OnboardWizard({ onComplete }: Props) {
347
416
 
348
417
  /* ── Navigation ── */
349
418
 
350
- // Steps: 0=Welcome, 1=Name, 2=Bot name + Handle, 3=Provider, 4=Whisper+Complete
419
+ // Steps: 0=Welcome, 1=Name, 2=Bot name + Handle, 3=Portal credentials, 4=Provider, 5=Whisper+Complete
420
+ const portalPassMatch = portalPass === portalPassConfirm;
421
+ const portalValid = portalUser.trim().length >= 3 && portalPass.length >= 6 && portalPassMatch;
422
+ const portalCanContinue = portalValid || (portalExists && portalPass.length === 0);
423
+
351
424
  const canNext = (() => {
352
425
  switch (step) {
353
426
  case 0: return true;
354
427
  case 1: return userName.trim().length > 0;
355
428
  case 2: return botName.trim().length >= 3; // Must have a bot name
356
- case 3: return !!(provider && model && isConnected);
357
- case 4: return true;
429
+ case 3: return portalCanContinue;
430
+ case 4: return !!(provider && model && isConnected);
431
+ case 5: return true;
358
432
  default: return false;
359
433
  }
360
434
  })();
@@ -380,6 +454,9 @@ export default function OnboardWizard({ onComplete }: Props) {
380
454
  apiKey: '',
381
455
  baseUrl: provider === 'ollama' ? baseUrl || undefined : undefined,
382
456
  whisperEnabled,
457
+ whisperKey: whisperEnabled ? whisperKey : '',
458
+ portalUser: portalUser.trim(),
459
+ portalPass,
383
460
  }),
384
461
  });
385
462
  onComplete();
@@ -492,135 +569,301 @@ export default function OnboardWizard({ onComplete }: Props) {
492
569
  This is your bot's name and permanent handle — access it from anywhere.
493
570
  </p>
494
571
 
495
- {/* Input */}
496
- <div className="relative mt-5">
497
- <input
498
- type="text"
499
- value={botName}
500
- onChange={(e) => onBotNameInput(e.target.value)}
501
- maxLength={30}
502
- placeholder="your-bot-name"
503
- spellCheck={false}
504
- autoCapitalize="none"
505
- autoCorrect="off"
506
- autoFocus
507
- disabled={registered}
508
- className={inputCls + ' pr-10 font-mono' + (registered ? ' opacity-50' : '')}
509
- />
510
- {handleStatus && botName.length > 0 && !registered && (
511
- <div className="absolute right-4 top-1/2 -translate-y-1/2">
512
- {handleStatus === 'checking' && (
513
- <div className="w-5 h-5 border-2 border-white/10 border-t-primary rounded-full animate-spin" />
514
- )}
515
- {handleStatus === 'available' && (
516
- <div className="w-6 h-6 rounded-full bg-emerald-500/15 flex items-center justify-center">
517
- <Check className="h-3.5 w-3.5 text-emerald-400" />
518
- </div>
519
- )}
520
- {handleStatus === 'taken' && (
521
- <div className="w-6 h-6 rounded-full bg-red-500/15 flex items-center justify-center">
522
- <svg className="w-3.5 h-3.5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
523
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
524
- </svg>
525
- </div>
526
- )}
527
- {handleStatus === 'invalid' && (
528
- <div className="w-6 h-6 rounded-full bg-amber-500/15 flex items-center justify-center">
529
- <svg className="w-3.5 h-3.5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
530
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3m0 4h.01" />
531
- </svg>
572
+ {/* Existing handle banner */}
573
+ {existingHandle && registered && !showChangeConfirm && (
574
+ <>
575
+ <div className="mt-4 bg-emerald-500/8 border border-emerald-500/15 rounded-xl px-4 py-3">
576
+ <div className="flex items-center gap-2">
577
+ <Check className="h-4 w-4 text-emerald-400" />
578
+ <p className="text-emerald-400/90 text-[13px] font-medium">Current handle</p>
579
+ </div>
580
+ <p className="text-emerald-400/60 text-[12px] mt-1 font-mono">{registeredUrl}</p>
581
+ </div>
582
+ <div className="flex gap-2 mt-4">
583
+ <button
584
+ onClick={next}
585
+ className="flex-1 py-3 bg-primary hover:bg-primary/90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2"
586
+ >
587
+ Continue
588
+ <ArrowRight className="h-4 w-4" />
589
+ </button>
590
+ <button
591
+ onClick={() => {
592
+ setShowChangeConfirm(true);
593
+ setRegistered(false);
594
+ setBotName('');
595
+ setHandleStatus(null);
596
+ setTierAvailability({});
597
+ }}
598
+ className="px-5 py-3 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"
599
+ >
600
+ Change
601
+ </button>
602
+ </div>
603
+ </>
604
+ )}
605
+
606
+ {/* Change confirmation alert */}
607
+ {showChangeConfirm && !registered && (
608
+ <div className="mt-4 bg-amber-500/8 border border-amber-500/20 rounded-xl px-4 py-3">
609
+ <p className="text-amber-400/90 text-[13px] font-medium">Changing your handle</p>
610
+ <p className="text-amber-400/60 text-[12px] mt-1">
611
+ Your current handle <span className="font-mono">{existingHandle?.url}</span> will be released and become available for others.
612
+ </p>
613
+ </div>
614
+ )}
615
+
616
+ {/* Input — shown for new claim or change flow */}
617
+ {(!existingHandle || showChangeConfirm || !registered) && !(existingHandle && registered && !showChangeConfirm) && (
618
+ <>
619
+ <div className="relative mt-5">
620
+ <input
621
+ type="text"
622
+ value={botName}
623
+ onChange={(e) => onBotNameInput(e.target.value)}
624
+ maxLength={30}
625
+ placeholder="your-bot-name"
626
+ spellCheck={false}
627
+ autoCapitalize="none"
628
+ autoCorrect="off"
629
+ autoFocus
630
+ disabled={registered}
631
+ className={inputCls + ' pr-10 font-mono' + (registered ? ' opacity-50' : '')}
632
+ />
633
+ {handleStatus && botName.length > 0 && !registered && (
634
+ <div className="absolute right-4 top-1/2 -translate-y-1/2">
635
+ {handleStatus === 'checking' && (
636
+ <div className="w-5 h-5 border-2 border-white/10 border-t-primary rounded-full animate-spin" />
637
+ )}
638
+ {handleStatus === 'invalid' && (
639
+ <div className="w-6 h-6 rounded-full bg-amber-500/15 flex items-center justify-center">
640
+ <svg className="w-3.5 h-3.5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
641
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3m0 4h.01" />
642
+ </svg>
643
+ </div>
644
+ )}
532
645
  </div>
533
646
  )}
534
647
  </div>
535
- )}
536
- </div>
537
648
 
538
- {/* Status messages */}
539
- {handleStatus === 'invalid' && handleError && (
540
- <p className="text-amber-400 text-[12px] mt-2">{handleError}</p>
541
- )}
542
- {handleStatus === 'taken' && (
543
- <p className="text-red-400 text-[12px] mt-2">This name is already taken</p>
544
- )}
649
+ {/* Status messages */}
650
+ {handleStatus === 'invalid' && handleError && (
651
+ <p className="text-amber-400 text-[12px] mt-2">{handleError}</p>
652
+ )}
545
653
 
546
- {/* Handle tier options */}
547
- {handleStatus === 'available' && botName.length > 0 && !registered && (
548
- <div className="space-y-2 mt-4">
549
- {HANDLES.map((h) => (
654
+ {/* Handle tier options — per-tier availability */}
655
+ {handleStatus === 'ready' && botName.length > 0 && !registered && (
656
+ <div className="space-y-2 mt-4">
657
+ {HANDLES.map((h) => {
658
+ const available = tierAvailability[h.tier];
659
+ const taken = available === false;
660
+ return (
661
+ <button
662
+ key={h.tier}
663
+ onClick={() => !taken && setSelectedTier(h.tier)}
664
+ disabled={taken}
665
+ className={`w-full flex items-center justify-between px-4 py-3 rounded-xl border transition-all duration-200 text-left ${
666
+ taken
667
+ ? 'border-white/[0.04] bg-transparent opacity-50 cursor-not-allowed'
668
+ : selectedTier === h.tier
669
+ ? h.highlight
670
+ ? 'border-primary/40 bg-primary/[0.06]'
671
+ : 'border-primary/30 bg-white/[0.04]'
672
+ : 'border-white/[0.06] bg-transparent hover:border-white/10 hover:bg-white/[0.02]'
673
+ }`}
674
+ >
675
+ <span className="font-mono text-[13px] text-white/70">
676
+ {h.label(botName)}
677
+ </span>
678
+ {taken ? (
679
+ <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">
680
+ Taken
681
+ </span>
682
+ ) : (
683
+ <span className={`text-[11px] font-medium px-2.5 py-0.5 rounded-full border ${h.badgeCls}`}>
684
+ {h.badge}
685
+ </span>
686
+ )}
687
+ </button>
688
+ );
689
+ })}
690
+ </div>
691
+ )}
692
+
693
+ {/* Registered success (after claiming) */}
694
+ {registered && (
695
+ <div className="mt-4 bg-emerald-500/8 border border-emerald-500/15 rounded-xl px-4 py-3">
696
+ <div className="flex items-center gap-2">
697
+ <Check className="h-4 w-4 text-emerald-400" />
698
+ <p className="text-emerald-400/90 text-[13px] font-medium">Handle claimed!</p>
699
+ </div>
700
+ <p className="text-emerald-400/60 text-[12px] mt-1 font-mono">{registeredUrl}</p>
701
+ </div>
702
+ )}
703
+
704
+ {/* Claim / Change button */}
705
+ {handleStatus === 'ready' && tierAvailability[selectedTier] && botName.length > 0 && !registered && (
550
706
  <button
551
- key={h.tier}
552
- onClick={() => setSelectedTier(h.tier)}
553
- className={`w-full flex items-center justify-between px-4 py-3 rounded-xl border transition-all duration-200 text-left ${
554
- selectedTier === h.tier
555
- ? h.highlight
556
- ? 'border-primary/40 bg-primary/[0.06]'
557
- : 'border-primary/30 bg-white/[0.04]'
558
- : 'border-white/[0.06] bg-transparent hover:border-white/10 hover:bg-white/[0.02]'
559
- }`}
707
+ onClick={showChangeConfirm ? onChangeHandle : onClaimHandle}
708
+ disabled={registering || changingHandle}
709
+ className="w-full mt-4 py-3 bg-primary hover:bg-primary/90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
560
710
  >
561
- <span className="font-mono text-[13px] text-white/70">
562
- {h.label(botName)}
563
- </span>
564
- <span className={`text-[11px] font-medium px-2.5 py-0.5 rounded-full border ${h.badgeCls}`}>
565
- {h.badge}
566
- </span>
711
+ {(registering || changingHandle) ? (
712
+ <><LoaderCircle className="h-4 w-4 animate-spin" />{showChangeConfirm ? 'Changing...' : 'Claiming...'}</>
713
+ ) : (
714
+ <>{showChangeConfirm ? 'Change Handle' : 'Claim & Continue'}<ArrowRight className="h-4 w-4" /></>
715
+ )}
567
716
  </button>
568
- ))}
569
- </div>
570
- )}
717
+ )}
571
718
 
572
- {/* Registered success */}
573
- {registered && (
574
- <div className="mt-4 bg-emerald-500/8 border border-emerald-500/15 rounded-xl px-4 py-3">
575
- <div className="flex items-center gap-2">
576
- <Check className="h-4 w-4 text-emerald-400" />
577
- <p className="text-emerald-400/90 text-[13px] font-medium">Handle claimed!</p>
578
- </div>
579
- <p className="text-emerald-400/60 text-[12px] mt-1 font-mono">{registeredUrl}</p>
580
- </div>
581
- )}
719
+ {/* Continue after claim */}
720
+ {registered && (
721
+ <button
722
+ onClick={next}
723
+ className="w-full mt-4 py-3 bg-primary hover:bg-primary/90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2"
724
+ >
725
+ Continue
726
+ <ArrowRight className="h-4 w-4" />
727
+ </button>
728
+ )}
582
729
 
583
- {/* Claim button */}
584
- {handleStatus === 'available' && botName.length > 0 && !registered && (
585
- <button
586
- onClick={onClaimHandle}
587
- disabled={registering}
588
- className="w-full mt-4 py-3 bg-primary hover:bg-primary/90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
589
- >
590
- {registering ? (
591
- <><LoaderCircle className="h-4 w-4 animate-spin" />Claiming...</>
592
- ) : (
593
- <>Claim & Continue<ArrowRight className="h-4 w-4" /></>
730
+ {/* Skip */}
731
+ {!registered && (!tierAvailability[selectedTier] || handleStatus !== 'ready') && botName.length >= 3 && (
732
+ <button
733
+ onClick={next}
734
+ className="w-full mt-4 py-2.5 text-white/30 hover:text-white/50 text-[13px] transition-colors flex items-center justify-center gap-2"
735
+ >
736
+ Skip handle for now
737
+ <ArrowRight className="h-3.5 w-3.5" />
738
+ </button>
594
739
  )}
595
- </button>
740
+
741
+ {/* Cancel change */}
742
+ {showChangeConfirm && !registered && (
743
+ <button
744
+ onClick={() => {
745
+ setShowChangeConfirm(false);
746
+ setBotName(existingHandle!.username);
747
+ setRegistered(true);
748
+ setRegisteredUrl(existingHandle!.url);
749
+ setSelectedTier(existingHandle!.tier);
750
+ setHandleStatus(null);
751
+ }}
752
+ className="w-full mt-2 py-2 text-white/25 hover:text-white/40 text-[12px] transition-colors"
753
+ >
754
+ Cancel — keep current handle
755
+ </button>
756
+ )}
757
+ </>
596
758
  )}
759
+ </div>
760
+ )}
597
761
 
598
- {/* Continue without claiming */}
599
- {registered && (
600
- <button
601
- onClick={next}
602
- className="w-full mt-4 py-3 bg-primary hover:bg-primary/90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2"
603
- >
604
- Continue
605
- <ArrowRight className="h-4 w-4" />
606
- </button>
762
+ {/* ── Step 3: Portal credentials ── */}
763
+ {step === 3 && (
764
+ <div>
765
+ <h1 className="text-xl font-bold text-white tracking-tight">
766
+ Secure your portal
767
+ </h1>
768
+ <p className="text-white/40 text-[13px] mt-1.5 leading-relaxed">
769
+ Create credentials to protect your dashboard. Anyone with your URL will need these to log in.
770
+ </p>
771
+
772
+ {/* Relay URL display + copy */}
773
+ {(registered && registeredUrl) && (
774
+ <div className="mt-4 flex items-center gap-2 bg-white/[0.03] border border-white/[0.06] rounded-xl px-4 py-3">
775
+ <span className="font-mono text-[13px] text-white/60 truncate flex-1">{registeredUrl}</span>
776
+ <button
777
+ onClick={() => {
778
+ navigator.clipboard.writeText(registeredUrl);
779
+ setPortalCopied(true);
780
+ setTimeout(() => setPortalCopied(false), 2000);
781
+ }}
782
+ className="shrink-0 text-white/30 hover:text-white/60 transition-colors"
783
+ >
784
+ {portalCopied ? (
785
+ <Check className="h-4 w-4 text-emerald-400" />
786
+ ) : (
787
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
788
+ <rect x="9" y="9" width="13" height="13" rx="2" />
789
+ <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
790
+ </svg>
791
+ )}
792
+ </button>
793
+ </div>
607
794
  )}
608
795
 
609
- {/* Skip if name is taken or not checked yet */}
610
- {!registered && (handleStatus !== 'available' || botName.length === 0) && botName.length >= 3 && (
611
- <button
612
- onClick={next}
613
- className="w-full mt-4 py-2.5 text-white/30 hover:text-white/50 text-[13px] transition-colors flex items-center justify-center gap-2"
614
- >
615
- Skip handle for now
616
- <ArrowRight className="h-3.5 w-3.5" />
617
- </button>
796
+ {/* Already configured hint */}
797
+ {portalExists && (
798
+ <div className="mt-4 bg-white/[0.02] border border-white/[0.06] rounded-xl px-4 py-2.5">
799
+ <p className="text-white/40 text-[12px]">Credentials already set. Leave password fields empty to keep your current password.</p>
800
+ </div>
618
801
  )}
802
+
803
+ {/* Username */}
804
+ <div className="mt-5">
805
+ <label className="text-[12px] text-white/40 font-medium mb-1.5 block">Username</label>
806
+ <input
807
+ type="text"
808
+ value={portalUser}
809
+ onChange={(e) => setPortalUser(e.target.value.replace(/\s/g, '').toLowerCase())}
810
+ placeholder="admin"
811
+ autoFocus
812
+ autoComplete="username"
813
+ className={inputCls + ' font-mono'}
814
+ />
815
+ {portalUser.length > 0 && portalUser.trim().length < 3 && (
816
+ <p className="text-amber-400/70 text-[11px] mt-1">At least 3 characters</p>
817
+ )}
818
+ </div>
819
+
820
+ {/* Password */}
821
+ <div className="mt-3">
822
+ <label className="text-[12px] text-white/40 font-medium mb-1.5 block">Password</label>
823
+ <input
824
+ type="password"
825
+ value={portalPass}
826
+ onChange={(e) => setPortalPass(e.target.value)}
827
+ placeholder="••••••••"
828
+ autoComplete="new-password"
829
+ onKeyDown={handleKeyDown}
830
+ className={inputCls}
831
+ />
832
+ {portalPass.length > 0 && portalPass.length < 6 && (
833
+ <p className="text-amber-400/70 text-[11px] mt-1">At least 6 characters</p>
834
+ )}
835
+ </div>
836
+
837
+ {/* Confirm password */}
838
+ <div className="mt-3">
839
+ <label className="text-[12px] text-white/40 font-medium mb-1.5 block">Confirm password</label>
840
+ <input
841
+ type="password"
842
+ value={portalPassConfirm}
843
+ onChange={(e) => setPortalPassConfirm(e.target.value)}
844
+ placeholder="••••••••"
845
+ autoComplete="new-password"
846
+ onKeyDown={handleKeyDown}
847
+ className={inputCls}
848
+ />
849
+ {portalPassConfirm.length > 0 && !portalPassMatch && (
850
+ <p className="text-red-400/70 text-[11px] mt-1">Passwords don't match</p>
851
+ )}
852
+ </div>
853
+
854
+ <button
855
+ onClick={next}
856
+ disabled={!canNext}
857
+ className="w-full mt-5 py-3 bg-primary hover:bg-primary/90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
858
+ >
859
+ Continue
860
+ <ArrowRight className="h-4 w-4" />
861
+ </button>
619
862
  </div>
620
863
  )}
621
864
 
622
- {/* ── Step 3: Provider + Auth + Model ── */}
623
- {step === 3 && (
865
+ {/* ── Step 4: Provider + Auth + Model ── */}
866
+ {step === 4 && (
624
867
  <div>
625
868
  <h1 className="text-xl font-bold text-white tracking-tight">
626
869
  Choose your AI provider
@@ -854,8 +1097,8 @@ export default function OnboardWizard({ onComplete }: Props) {
854
1097
  </div>
855
1098
  )}
856
1099
 
857
- {/* ── Step 4: Whisper (optional) + Complete ── */}
858
- {step === 4 && (
1100
+ {/* ── Step 5: Whisper (optional) + Complete ── */}
1101
+ {step === 5 && (
859
1102
  <div>
860
1103
  <div className="flex items-center gap-2 mb-1">
861
1104
  <h1 className="text-xl font-bold text-white tracking-tight">
@@ -896,8 +1139,23 @@ export default function OnboardWizard({ onComplete }: Props) {
896
1139
  </button>
897
1140
 
898
1141
  {whisperEnabled && (
899
- <div className="mt-3 bg-white/[0.02] border border-white/[0.06] rounded-xl px-4 py-3">
900
- <div className="flex items-start gap-2.5">
1142
+ <div className="mt-3">
1143
+ <label className="text-[12px] text-white/40 font-medium mb-1.5 block">OpenAI API Key</label>
1144
+ <input
1145
+ type="password"
1146
+ value={whisperKey}
1147
+ onChange={(e) => setWhisperKey(e.target.value.trim())}
1148
+ placeholder="sk-..."
1149
+ autoComplete="off"
1150
+ className={inputCls + ' font-mono text-[13px]'}
1151
+ />
1152
+ {whisperKey.length > 0 && !whisperKey.startsWith('sk-') && (
1153
+ <p className="text-amber-400/70 text-[11px] mt-1">Key should start with sk-</p>
1154
+ )}
1155
+ {whisperKey.length > 0 && whisperKey.startsWith('sk-') && whisperKey.length < 20 && (
1156
+ <p className="text-amber-400/70 text-[11px] mt-1">Key looks too short</p>
1157
+ )}
1158
+ <div className="flex items-start gap-2.5 mt-3 bg-white/[0.02] border border-white/[0.06] rounded-xl px-4 py-3">
901
1159
  <Mic className="h-4 w-4 text-primary/60 mt-0.5 shrink-0" />
902
1160
  <p className="text-white/35 text-[12px] leading-relaxed">
903
1161
  Users will see a microphone button in the chat. Audio is sent to OpenAI's Whisper API for transcription, then processed as a regular text message.
@@ -908,7 +1166,7 @@ export default function OnboardWizard({ onComplete }: Props) {
908
1166
 
909
1167
  <button
910
1168
  onClick={handleComplete}
911
- disabled={saving}
1169
+ disabled={saving || (whisperEnabled && (!whisperKey.startsWith('sk-') || whisperKey.length < 20))}
912
1170
  className="w-full mt-5 py-3 bg-primary hover:bg-primary/90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
913
1171
  >
914
1172
  {saving ? (