fluxy-bot 0.7.1 → 0.7.2

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.
@@ -171,6 +171,8 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
171
171
  const [totpDisableCode, setTotpDisableCode] = useState('');
172
172
  const [totpDisableError, setTotpDisableError] = useState('');
173
173
  const [isMobileDevice, setIsMobileDevice] = useState(false);
174
+ // Sub-phase within step 3: 'password' | 'totp-setup' | 'recovery'
175
+ const [step3Phase, setStep3Phase] = useState<'password' | 'totp-setup' | 'recovery'>('password');
174
176
 
175
177
  // Pre-fill guard
176
178
  const prefillDone = useRef(false);
@@ -505,9 +507,10 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
505
507
  return registered;
506
508
  }
507
509
  case 3: {
510
+ // Block "Continue" while in a TOTP sub-phase
511
+ if (step3Phase !== 'password') return false;
508
512
  if (!portalCanContinue) return false;
509
513
  if (totpEnabled && !totpVerified) return false;
510
- if (recoveryCodes.length > 0 && !recoveryCodesCopied) return false;
511
514
  return true;
512
515
  }
513
516
  case 4: return !!(provider && model && isConnected);
@@ -971,243 +974,300 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
971
974
  </div>
972
975
  )}
973
976
 
974
- {/* ── Step 3: Password ── */}
977
+ {/* ── Step 3: Password + 2FA (sub-phase flow) ── */}
975
978
  {step === 3 && (
976
979
  <div>
977
- <h1 className="text-xl font-bold text-white tracking-tight">
978
- Set a password
979
- </h1>
980
- <p className="text-white/40 text-[13px] mt-1.5 leading-relaxed">
981
- You'll need this password to access your agent's chat. Keep it safe — anyone with your URL will need it to log in.
982
- </p>
980
+ <AnimatePresence mode="wait">
981
+ {/* ── Phase: Password ── */}
982
+ {step3Phase === 'password' && (
983
+ <motion.div
984
+ key="password"
985
+ initial={{ opacity: 0, x: -20 }}
986
+ animate={{ opacity: 1, x: 0 }}
987
+ exit={{ opacity: 0, x: -20 }}
988
+ transition={{ duration: 0.15 }}
989
+ >
990
+ <h1 className="text-xl font-bold text-white tracking-tight">
991
+ Set a password
992
+ </h1>
993
+ <p className="text-white/40 text-[13px] mt-1.5 leading-relaxed">
994
+ You'll need this password to access your agent's chat. Keep it safe — anyone with your URL will need it to log in.
995
+ </p>
983
996
 
984
- {/* Already configured hint */}
985
- {portalExists && (
986
- <div className="mt-4 bg-white/[0.02] border border-white/[0.06] rounded-xl px-4 py-2.5">
987
- <p className="text-white/40 text-[12px]">Password already set. Leave fields empty to keep your current password, or enter your current password to change it.</p>
988
- </div>
989
- )}
997
+ {portalExists && (
998
+ <div className="mt-4 bg-white/[0.02] border border-white/[0.06] rounded-xl px-4 py-2.5">
999
+ <p className="text-white/40 text-[12px]">Password already set. Leave fields empty to keep your current password, or enter your current password to change it.</p>
1000
+ </div>
1001
+ )}
990
1002
 
991
- {/* Current password (only when changing existing credentials) */}
992
- {portalExists && (
993
- <div className="mt-5">
994
- <label className="text-[12px] text-white/40 font-medium mb-1.5 block">Current password</label>
995
- <div className="flex items-center gap-2">
996
- <input
997
- type="password"
998
- value={portalOldPass}
999
- onChange={(e) => { setPortalOldPass(e.target.value); setPortalOldPassError(''); setPortalOldPassVerified(false); }}
1000
- placeholder="Enter current password to change it"
1001
- autoComplete="current-password"
1002
- autoFocus
1003
- className={inputCls + ' flex-1'}
1004
- />
1005
- {portalOldPass.length > 0 && !portalOldPassVerified && (
1003
+ {portalExists && (
1004
+ <div className="mt-5">
1005
+ <label className="text-[12px] text-white/40 font-medium mb-1.5 block">Current password</label>
1006
+ <div className="flex items-center gap-2">
1007
+ <input
1008
+ type="password"
1009
+ value={portalOldPass}
1010
+ onChange={(e) => { setPortalOldPass(e.target.value); setPortalOldPassError(''); setPortalOldPassVerified(false); }}
1011
+ placeholder="Enter current password to change it"
1012
+ autoComplete="current-password"
1013
+ autoFocus
1014
+ className={inputCls + ' flex-1'}
1015
+ />
1016
+ {portalOldPass.length > 0 && !portalOldPassVerified && (
1017
+ <button
1018
+ onClick={async () => {
1019
+ setPortalVerifying(true);
1020
+ setPortalOldPassError('');
1021
+ try {
1022
+ const res = await fetch('/api/portal/verify-password', {
1023
+ method: 'POST',
1024
+ headers: { 'Content-Type': 'application/json' },
1025
+ body: JSON.stringify({ password: portalOldPass }),
1026
+ });
1027
+ const data = await res.json();
1028
+ if (data.valid) {
1029
+ setPortalOldPassVerified(true);
1030
+ } else {
1031
+ setPortalOldPassError('Incorrect password');
1032
+ }
1033
+ } catch {
1034
+ setPortalOldPassError('Could not verify');
1035
+ } finally {
1036
+ setPortalVerifying(false);
1037
+ }
1038
+ }}
1039
+ disabled={portalVerifying}
1040
+ className="shrink-0 px-4 py-3 bg-white/[0.06] hover:bg-white/[0.1] text-white/60 text-[13px] font-medium rounded-xl transition-colors disabled:opacity-40"
1041
+ >
1042
+ {portalVerifying ? <LoaderCircle className="h-4 w-4 animate-spin" /> : 'Verify'}
1043
+ </button>
1044
+ )}
1045
+ {portalOldPassVerified && (
1046
+ <div className="shrink-0 w-10 h-10 flex items-center justify-center">
1047
+ <Check className="h-4 w-4 text-emerald-400" />
1048
+ </div>
1049
+ )}
1050
+ </div>
1051
+ {portalOldPassError && (
1052
+ <p className="text-red-400/70 text-[11px] mt-1">{portalOldPassError}</p>
1053
+ )}
1054
+ </div>
1055
+ )}
1056
+
1057
+ {(!portalExists || portalOldPassVerified) && (
1058
+ <>
1059
+ <div className={portalExists ? 'mt-3' : 'mt-5'}>
1060
+ <label className="text-[12px] text-white/40 font-medium mb-1.5 block">
1061
+ {portalExists ? 'New password' : 'Password'}
1062
+ </label>
1063
+ <input
1064
+ type="password"
1065
+ value={portalPass}
1066
+ onChange={(e) => setPortalPass(e.target.value)}
1067
+ placeholder="••••••••"
1068
+ autoComplete="new-password"
1069
+ autoFocus={!portalExists}
1070
+ onKeyDown={handleKeyDown}
1071
+ className={inputCls}
1072
+ />
1073
+ {portalPass.length > 0 && portalPass.length < 6 && (
1074
+ <p className="text-amber-400/70 text-[11px] mt-1">At least 6 characters</p>
1075
+ )}
1076
+ </div>
1077
+
1078
+ <div className="mt-3">
1079
+ <label className="text-[12px] text-white/40 font-medium mb-1.5 block">
1080
+ {portalExists ? 'Confirm new password' : 'Confirm password'}
1081
+ </label>
1082
+ <input
1083
+ type="password"
1084
+ value={portalPassConfirm}
1085
+ onChange={(e) => setPortalPassConfirm(e.target.value)}
1086
+ placeholder="••••••••"
1087
+ autoComplete="new-password"
1088
+ onKeyDown={handleKeyDown}
1089
+ className={inputCls}
1090
+ />
1091
+ {portalPassConfirm.length > 0 && !portalPassMatch && (
1092
+ <p className="text-red-400/70 text-[11px] mt-1">Passwords don't match</p>
1093
+ )}
1094
+ </div>
1095
+ </>
1096
+ )}
1097
+
1098
+ {/* ── 2FA toggle card ── */}
1099
+ <div className="mt-5 border border-white/[0.06] rounded-xl overflow-hidden">
1006
1100
  <button
1007
1101
  onClick={async () => {
1008
- setPortalVerifying(true);
1009
- setPortalOldPassError('');
1010
- try {
1011
- const res = await fetch('/api/portal/verify-password', {
1012
- method: 'POST',
1013
- headers: { 'Content-Type': 'application/json' },
1014
- body: JSON.stringify({ password: portalOldPass }),
1015
- });
1016
- const data = await res.json();
1017
- if (data.valid) {
1018
- setPortalOldPassVerified(true);
1102
+ if (totpEnabled && totpVerified) {
1103
+ setTotpDisabling(true);
1104
+ return;
1105
+ }
1106
+ if (!totpEnabled) {
1107
+ // Enable go to setup phase
1108
+ setTotpEnabled(true);
1109
+ setTotpError('');
1110
+ setTotpCode('');
1111
+ // Fetch QR if not already loaded
1112
+ if (!totpQrUri) {
1113
+ try {
1114
+ const res = await fetch('/api/portal/totp/setup', {
1115
+ method: 'POST',
1116
+ headers: { 'Content-Type': 'application/json' },
1117
+ body: JSON.stringify({ password: portalExists ? portalOldPass : portalPass }),
1118
+ });
1119
+ const data = await res.json();
1120
+ if (res.ok) {
1121
+ setTotpSecret(data.secret);
1122
+ setTotpQrUri(data.qrDataUri);
1123
+ setTotpOtpauthUri(data.otpauthUri);
1124
+ setStep3Phase('totp-setup');
1125
+ } else {
1126
+ setTotpError(data.error || 'Setup failed');
1127
+ setTotpEnabled(false);
1128
+ }
1129
+ } catch {
1130
+ setTotpError('Could not reach server');
1131
+ setTotpEnabled(false);
1132
+ }
1019
1133
  } else {
1020
- setPortalOldPassError('Incorrect password');
1134
+ setStep3Phase('totp-setup');
1021
1135
  }
1022
- } catch {
1023
- setPortalOldPassError('Could not verify');
1024
- } finally {
1025
- setPortalVerifying(false);
1136
+ } else {
1137
+ // Disable (setup incomplete)
1138
+ setTotpEnabled(false);
1139
+ setTotpQrUri('');
1140
+ setTotpSecret('');
1141
+ setTotpOtpauthUri('');
1026
1142
  }
1027
1143
  }}
1028
- disabled={portalVerifying}
1029
- className="shrink-0 px-4 py-3 bg-white/[0.06] hover:bg-white/[0.1] text-white/60 text-[13px] font-medium rounded-xl transition-colors disabled:opacity-40"
1144
+ type="button"
1145
+ className="w-full flex items-center gap-3 px-4 py-3 text-left"
1030
1146
  >
1031
- {portalVerifying ? <LoaderCircle className="h-4 w-4 animate-spin" /> : 'Verify'}
1147
+ <div className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 ${totpEnabled && totpVerified ? 'bg-emerald-500/10' : 'bg-white/[0.04]'}`}>
1148
+ {totpEnabled && totpVerified ? (
1149
+ <ShieldCheck className="h-[18px] w-[18px] text-emerald-400" />
1150
+ ) : (
1151
+ <Shield className="h-[18px] w-[18px] text-white/30" />
1152
+ )}
1153
+ </div>
1154
+ <div className="flex-1 min-w-0">
1155
+ <div className="flex items-center gap-2">
1156
+ <span className="text-[13px] font-medium text-white">Two-Factor Authentication</span>
1157
+ {(tunnelMode === 'quick' || tunnelMode === 'named') && !totpEnabled && (
1158
+ <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-500/10 text-amber-400 border border-amber-500/20 shrink-0">Recommended</span>
1159
+ )}
1160
+ {totpEnabled && totpVerified && (
1161
+ <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 shrink-0">Active</span>
1162
+ )}
1163
+ </div>
1164
+ <p className="text-[11px] text-white/30 mt-0.5">
1165
+ {totpEnabled && totpVerified ? 'Require a 6-digit code from your authenticator app' : 'Add an extra layer of security'}
1166
+ </p>
1167
+ </div>
1168
+ <div className={`w-10 h-6 rounded-full transition-colors relative shrink-0 ${totpEnabled ? 'bg-emerald-500' : 'bg-white/10'}`}>
1169
+ <div className={`absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform ${totpEnabled ? 'translate-x-5' : 'translate-x-1'}`} />
1170
+ </div>
1032
1171
  </button>
1033
- )}
1034
- {portalOldPassVerified && (
1035
- <div className="shrink-0 w-10 h-10 flex items-center justify-center">
1036
- <Check className="h-4 w-4 text-emerald-400" />
1037
- </div>
1038
- )}
1039
- </div>
1040
- {portalOldPassError && (
1041
- <p className="text-red-400/70 text-[11px] mt-1">{portalOldPassError}</p>
1042
- )}
1043
- </div>
1044
- )}
1045
1172
 
1046
- {/* New password + confirm (shown when no existing credentials, or when old password verified) */}
1047
- {(!portalExists || portalOldPassVerified) && (
1048
- <>
1049
- <div className={portalExists ? 'mt-3' : 'mt-5'}>
1050
- <label className="text-[12px] text-white/40 font-medium mb-1.5 block">
1051
- {portalExists ? 'New password' : 'Password'}
1052
- </label>
1053
- <input
1054
- type="password"
1055
- value={portalPass}
1056
- onChange={(e) => setPortalPass(e.target.value)}
1057
- placeholder="••••••••"
1058
- autoComplete="new-password"
1059
- autoFocus={!portalExists}
1060
- onKeyDown={handleKeyDown}
1061
- className={inputCls}
1062
- />
1063
- {portalPass.length > 0 && portalPass.length < 6 && (
1064
- <p className="text-amber-400/70 text-[11px] mt-1">At least 6 characters</p>
1065
- )}
1066
- </div>
1067
-
1068
- <div className="mt-3">
1069
- <label className="text-[12px] text-white/40 font-medium mb-1.5 block">
1070
- {portalExists ? 'Confirm new password' : 'Confirm password'}
1071
- </label>
1072
- <input
1073
- type="password"
1074
- value={portalPassConfirm}
1075
- onChange={(e) => setPortalPassConfirm(e.target.value)}
1076
- placeholder="••••••••"
1077
- autoComplete="new-password"
1078
- onKeyDown={handleKeyDown}
1079
- className={inputCls}
1080
- />
1081
- {portalPassConfirm.length > 0 && !portalPassMatch && (
1082
- <p className="text-red-400/70 text-[11px] mt-1">Passwords don't match</p>
1083
- )}
1084
- </div>
1085
- </>
1086
- )}
1087
-
1088
- {/* ── Two-Factor Authentication ── */}
1089
- <div className="mt-6 border border-white/[0.06] rounded-xl overflow-hidden">
1090
- <button
1091
- onClick={async () => {
1092
- if (totpEnabled && totpVerified) {
1093
- // Already enabled — show disable flow
1094
- setTotpDisabling(true);
1095
- return;
1096
- }
1097
- const newVal = !totpEnabled;
1098
- setTotpEnabled(newVal);
1099
- setTotpError('');
1100
- setTotpCode('');
1101
- setRecoveryCodes([]);
1102
- setRecoveryCodesCopied(false);
1103
- if (newVal && !totpQrUri) {
1104
- // Start setup
1105
- try {
1106
- const res = await fetch('/api/portal/totp/setup', {
1107
- method: 'POST',
1108
- headers: { 'Content-Type': 'application/json' },
1109
- body: JSON.stringify({ password: portalExists ? portalOldPass : portalPass }),
1110
- });
1111
- const data = await res.json();
1112
- if (res.ok) {
1113
- setTotpSecret(data.secret);
1114
- setTotpQrUri(data.qrDataUri);
1115
- setTotpOtpauthUri(data.otpauthUri);
1116
- } else {
1117
- setTotpError(data.error || 'Setup failed');
1118
- setTotpEnabled(false);
1119
- }
1120
- } catch {
1121
- setTotpError('Could not reach server');
1122
- setTotpEnabled(false);
1123
- }
1124
- }
1125
- }}
1126
- type="button"
1127
- className="w-full flex items-center gap-3 px-4 py-3 text-left"
1128
- >
1129
- <div className={`w-9 h-9 rounded-lg flex items-center justify-center ${totpEnabled && totpVerified ? 'bg-emerald-500/10' : 'bg-white/[0.04]'}`}>
1130
- {totpEnabled && totpVerified ? (
1131
- <ShieldCheck className="h-4.5 w-4.5 text-emerald-400" />
1132
- ) : (
1133
- <Shield className="h-4.5 w-4.5 text-white/30" />
1134
- )}
1135
- </div>
1136
- <div className="flex-1 min-w-0">
1137
- <div className="flex items-center gap-2">
1138
- <span className="text-[13px] font-medium text-white">Two-Factor Authentication</span>
1139
- {(tunnelMode === 'quick' || tunnelMode === 'named') && !totpEnabled && (
1140
- <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-500/10 text-amber-400 border border-amber-500/20">Recommended</span>
1173
+ {/* Inline disable flow */}
1174
+ {totpDisabling && (
1175
+ <div className="px-4 pb-3 border-t border-white/[0.06]">
1176
+ <p className="text-[12px] text-white/40 mt-3 mb-2">Enter your current TOTP code to disable 2FA:</p>
1177
+ <div className="flex items-center gap-2">
1178
+ <input
1179
+ type="text"
1180
+ inputMode="numeric"
1181
+ autoComplete="one-time-code"
1182
+ maxLength={6}
1183
+ value={totpDisableCode}
1184
+ onChange={(e) => { setTotpDisableCode(e.target.value.replace(/\D/g, '')); setTotpDisableError(''); }}
1185
+ placeholder="000000"
1186
+ className={inputSmCls + ' flex-1 tracking-[0.3em] text-center font-mono'}
1187
+ />
1188
+ <button
1189
+ onClick={async () => {
1190
+ if (totpDisableCode.length !== 6) return;
1191
+ try {
1192
+ const res = await fetch('/api/portal/totp/disable', {
1193
+ method: 'POST',
1194
+ headers: { 'Content-Type': 'application/json' },
1195
+ body: JSON.stringify({ password: portalExists ? portalOldPass : portalPass, code: totpDisableCode }),
1196
+ });
1197
+ const data = await res.json();
1198
+ if (res.ok) {
1199
+ setTotpEnabled(false);
1200
+ setTotpVerified(false);
1201
+ setTotpSecret('');
1202
+ setTotpQrUri('');
1203
+ setTotpOtpauthUri('');
1204
+ setTotpDisabling(false);
1205
+ setTotpDisableCode('');
1206
+ setRecoveryCodes([]);
1207
+ setRecoveryCodesCopied(false);
1208
+ } else {
1209
+ setTotpDisableError(data.error || 'Failed to disable');
1210
+ }
1211
+ } catch {
1212
+ setTotpDisableError('Could not reach server');
1213
+ }
1214
+ }}
1215
+ disabled={totpDisableCode.length !== 6}
1216
+ className="shrink-0 px-4 py-2.5 bg-red-500/10 hover:bg-red-500/20 text-red-400 text-[13px] font-medium rounded-xl transition-colors disabled:opacity-40"
1217
+ >
1218
+ Disable
1219
+ </button>
1220
+ </div>
1221
+ {totpDisableError && <p className="text-red-400/70 text-[11px] mt-1">{totpDisableError}</p>}
1222
+ <button onClick={() => { setTotpDisabling(false); setTotpDisableCode(''); setTotpDisableError(''); }} className="text-[11px] text-white/30 hover:text-white/50 mt-2">Cancel</button>
1223
+ </div>
1141
1224
  )}
1142
- {totpEnabled && totpVerified && (
1143
- <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">Active</span>
1225
+
1226
+ {totpError && step3Phase === 'password' && (
1227
+ <div className="mx-4 mb-3 bg-red-500/8 border border-red-500/15 rounded-xl px-3 py-2">
1228
+ <p className="text-red-400/90 text-[11px]">{totpError}</p>
1229
+ </div>
1144
1230
  )}
1145
1231
  </div>
1146
- <p className="text-[11px] text-white/30 mt-0.5">
1147
- {totpEnabled && totpVerified ? 'Require a 6-digit code from your authenticator app' : 'Add an extra layer of security with an authenticator app'}
1148
- </p>
1149
- </div>
1150
- <div className={`w-10 h-6 rounded-full transition-colors relative ${totpEnabled ? 'bg-emerald-500' : 'bg-white/10'}`}>
1151
- <div className={`absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform ${totpEnabled ? 'translate-x-5' : 'translate-x-1'}`} />
1152
- </div>
1153
- </button>
1154
1232
 
1155
- {/* Disable 2FA confirmation */}
1156
- {totpDisabling && (
1157
- <div className="px-4 pb-4 border-t border-white/[0.06]">
1158
- <p className="text-[12px] text-white/40 mt-3 mb-2">Enter your current TOTP code to disable 2FA:</p>
1159
- <div className="flex items-center gap-2">
1160
- <input
1161
- type="text"
1162
- inputMode="numeric"
1163
- autoComplete="one-time-code"
1164
- maxLength={6}
1165
- value={totpDisableCode}
1166
- onChange={(e) => { setTotpDisableCode(e.target.value.replace(/\D/g, '')); setTotpDisableError(''); }}
1167
- placeholder="000000"
1168
- className={inputSmCls + ' flex-1 tracking-[0.3em] text-center font-mono'}
1169
- />
1233
+ <button
1234
+ onClick={next}
1235
+ disabled={!canNext}
1236
+ className="w-full mt-5 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
1237
+ >
1238
+ Continue
1239
+ <ArrowRight className="h-4 w-4" />
1240
+ </button>
1241
+ </motion.div>
1242
+ )}
1243
+
1244
+ {/* ── Phase: TOTP Setup (QR + verify) ── */}
1245
+ {step3Phase === 'totp-setup' && (
1246
+ <motion.div
1247
+ key="totp-setup"
1248
+ initial={{ opacity: 0, x: 20 }}
1249
+ animate={{ opacity: 1, x: 0 }}
1250
+ exit={{ opacity: 0, x: 20 }}
1251
+ transition={{ duration: 0.15 }}
1252
+ >
1253
+ <div className="flex items-center gap-3 mb-1">
1170
1254
  <button
1171
- onClick={async () => {
1172
- if (totpDisableCode.length !== 6) return;
1173
- try {
1174
- const res = await fetch('/api/portal/totp/disable', {
1175
- method: 'POST',
1176
- headers: { 'Content-Type': 'application/json' },
1177
- body: JSON.stringify({ password: portalExists ? portalOldPass : portalPass, code: totpDisableCode }),
1178
- });
1179
- const data = await res.json();
1180
- if (res.ok) {
1181
- setTotpEnabled(false);
1182
- setTotpVerified(false);
1183
- setTotpSecret('');
1184
- setTotpQrUri('');
1185
- setTotpOtpauthUri('');
1186
- setTotpDisabling(false);
1187
- setTotpDisableCode('');
1188
- setRecoveryCodes([]);
1189
- setRecoveryCodesCopied(false);
1190
- } else {
1191
- setTotpDisableError(data.error || 'Failed to disable');
1192
- }
1193
- } catch {
1194
- setTotpDisableError('Could not reach server');
1195
- }
1255
+ onClick={() => {
1256
+ setTotpEnabled(false);
1257
+ setTotpQrUri('');
1258
+ setTotpSecret('');
1259
+ setTotpOtpauthUri('');
1260
+ setTotpCode('');
1261
+ setTotpError('');
1262
+ setStep3Phase('password');
1196
1263
  }}
1197
- disabled={totpDisableCode.length !== 6}
1198
- className="shrink-0 px-4 py-2.5 bg-red-500/10 hover:bg-red-500/20 text-red-400 text-[13px] font-medium rounded-xl transition-colors disabled:opacity-40"
1264
+ className="w-7 h-7 rounded-full bg-white/[0.04] flex items-center justify-center text-white/40 hover:text-white/70 transition-colors shrink-0"
1199
1265
  >
1200
- Disable
1266
+ <ArrowLeft className="h-4 w-4" />
1201
1267
  </button>
1268
+ <h1 className="text-xl font-bold text-white tracking-tight">Set up 2FA</h1>
1202
1269
  </div>
1203
- {totpDisableError && <p className="text-red-400/70 text-[11px] mt-1">{totpDisableError}</p>}
1204
- <button onClick={() => { setTotpDisabling(false); setTotpDisableCode(''); setTotpDisableError(''); }} className="text-[11px] text-white/30 hover:text-white/50 mt-2">Cancel</button>
1205
- </div>
1206
- )}
1207
1270
 
1208
- {/* TOTP Setup flow */}
1209
- {totpEnabled && !totpVerified && !totpDisabling && (
1210
- <div className="px-4 pb-4 border-t border-white/[0.06]">
1211
1271
  {totpError && (
1212
1272
  <div className="mt-3 bg-red-500/8 border border-red-500/15 rounded-xl px-3 py-2">
1213
1273
  <p className="text-red-400/90 text-[11px]">{totpError}</p>
@@ -1216,45 +1276,56 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1216
1276
 
1217
1277
  {totpQrUri && (
1218
1278
  <>
1219
- <p className="text-[12px] text-white/40 mt-3 mb-3">
1220
- Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.)
1221
- </p>
1222
-
1223
- {/* QR Code / Mobile deep link */}
1279
+ {/* Desktop: horizontal QR + instructions | Mobile: authenticator link */}
1224
1280
  {isMobileDevice ? (
1225
- <div className="flex flex-col items-center gap-3">
1281
+ <div className="mt-4">
1282
+ <p className="text-white/40 text-[13px] leading-relaxed mb-4">
1283
+ Add Fluxy to your authenticator app, then enter the 6-digit code below to confirm.
1284
+ </p>
1226
1285
  <a
1227
1286
  href={totpOtpauthUri}
1228
- className="w-full py-2.5 bg-[#AF27E3]/10 hover:bg-[#AF27E3]/20 text-[#AF27E3] text-[13px] font-medium rounded-xl transition-colors flex items-center justify-center gap-2"
1287
+ className="w-full py-3 bg-[#AF27E3]/10 hover:bg-[#AF27E3]/20 text-[#AF27E3] text-[14px] font-medium rounded-xl transition-colors flex items-center justify-center gap-2"
1229
1288
  >
1230
1289
  <Smartphone className="h-4 w-4" />
1231
1290
  Open in Authenticator
1232
1291
  </a>
1233
1292
  <button
1234
1293
  onClick={() => { navigator.clipboard.writeText(totpSecret); }}
1235
- className="text-[11px] text-white/30 hover:text-white/50 flex items-center gap-1"
1294
+ className="w-full mt-2 text-[11px] text-white/25 hover:text-white/40 flex items-center justify-center gap-1 py-1 transition-colors"
1236
1295
  >
1237
1296
  <Copy className="h-3 w-3" />
1238
- Copy secret key
1297
+ Or copy secret key manually
1239
1298
  </button>
1240
1299
  </div>
1241
1300
  ) : (
1242
- <div className="flex flex-col items-center gap-3">
1243
- <div className="bg-white rounded-xl p-2">
1244
- <img src={totpQrUri} alt="TOTP QR Code" className="w-48 h-48" />
1301
+ <div className="mt-4 flex gap-4 items-start">
1302
+ {/* QR Code — compact */}
1303
+ <div className="shrink-0">
1304
+ <div className="bg-white rounded-xl p-1.5">
1305
+ <img src={totpQrUri} alt="TOTP QR Code" className="w-[140px] h-[140px]" />
1306
+ </div>
1307
+ </div>
1308
+ {/* Instructions */}
1309
+ <div className="flex-1 min-w-0 pt-1">
1310
+ <p className="text-white/40 text-[13px] leading-relaxed">
1311
+ Scan this QR code with your authenticator app.
1312
+ </p>
1313
+ <p className="text-white/25 text-[11px] mt-2 leading-relaxed">
1314
+ Google Authenticator, Authy, 1Password, or any TOTP app.
1315
+ </p>
1316
+ <button
1317
+ onClick={() => { navigator.clipboard.writeText(totpSecret); }}
1318
+ className="mt-3 text-[11px] text-white/25 hover:text-white/40 flex items-center gap-1 transition-colors"
1319
+ >
1320
+ <Copy className="h-3 w-3" />
1321
+ Copy secret key
1322
+ </button>
1245
1323
  </div>
1246
- <button
1247
- onClick={() => { navigator.clipboard.writeText(totpSecret); }}
1248
- className="text-[11px] text-white/30 hover:text-white/50 flex items-center gap-1"
1249
- >
1250
- <Copy className="h-3 w-3" />
1251
- Copy secret key: {totpSecret.slice(0, 8)}...
1252
- </button>
1253
1324
  </div>
1254
1325
  )}
1255
1326
 
1256
1327
  {/* Verification input */}
1257
- <div className="mt-4">
1328
+ <div className="mt-5">
1258
1329
  <label className="text-[12px] text-white/40 font-medium mb-1.5 block">Enter the 6-digit code from your app</label>
1259
1330
  <div className="flex items-center gap-2">
1260
1331
  <input
@@ -1265,7 +1336,8 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1265
1336
  value={totpCode}
1266
1337
  onChange={(e) => { setTotpCode(e.target.value.replace(/\D/g, '')); setTotpError(''); }}
1267
1338
  placeholder="000000"
1268
- className={inputSmCls + ' flex-1 tracking-[0.3em] text-center font-mono'}
1339
+ autoFocus
1340
+ className={inputCls + ' tracking-[0.3em] text-center font-mono flex-1'}
1269
1341
  />
1270
1342
  <button
1271
1343
  onClick={async () => {
@@ -1282,6 +1354,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1282
1354
  if (res.ok && data.success) {
1283
1355
  setTotpVerified(true);
1284
1356
  setRecoveryCodes(data.recoveryCodes || []);
1357
+ setStep3Phase('recovery');
1285
1358
  } else {
1286
1359
  setTotpError(data.error || 'Verification failed');
1287
1360
  }
@@ -1292,7 +1365,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1292
1365
  }
1293
1366
  }}
1294
1367
  disabled={totpCode.length !== 6 || totpVerifying}
1295
- className="shrink-0 px-4 py-2.5 bg-white/[0.06] hover:bg-white/[0.1] text-white/60 text-[13px] font-medium rounded-xl transition-colors disabled:opacity-40"
1368
+ className="shrink-0 px-5 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-xl transition-colors flex items-center gap-2 disabled:opacity-40"
1296
1369
  >
1297
1370
  {totpVerifying ? <LoaderCircle className="h-4 w-4 animate-spin" /> : 'Verify'}
1298
1371
  </button>
@@ -1300,54 +1373,72 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1300
1373
  </div>
1301
1374
  </>
1302
1375
  )}
1303
- </div>
1376
+ </motion.div>
1304
1377
  )}
1305
1378
 
1306
- {/* Recovery codes */}
1307
- {recoveryCodes.length > 0 && (
1308
- <div className="px-4 pb-4 border-t border-white/[0.06]">
1309
- <div className="mt-3 bg-amber-500/5 border border-amber-500/15 rounded-xl px-3 py-2.5">
1310
- <p className="text-amber-400 text-[12px] font-medium mb-1">Save your recovery codes</p>
1311
- <p className="text-white/30 text-[11px] mb-3">
1312
- If you lose access to your authenticator, use one of these codes to sign in. Each code works once.
1313
- </p>
1314
- <div className="grid grid-cols-2 gap-1.5">
1315
- {recoveryCodes.map((code, i) => (
1316
- <div key={i} className="bg-black/30 rounded-lg px-2.5 py-1.5 text-center">
1317
- <code className="text-[12px] text-white/60 font-mono tracking-wider">{code}</code>
1318
- </div>
1319
- ))}
1379
+ {/* ── Phase: Recovery codes ── */}
1380
+ {step3Phase === 'recovery' && (
1381
+ <motion.div
1382
+ key="recovery"
1383
+ initial={{ opacity: 0, x: 20 }}
1384
+ animate={{ opacity: 1, x: 0 }}
1385
+ exit={{ opacity: 0, x: 20 }}
1386
+ transition={{ duration: 0.15 }}
1387
+ >
1388
+ <div className="flex items-center gap-3 mb-1">
1389
+ <div className="w-9 h-9 rounded-xl bg-emerald-500/10 flex items-center justify-center shrink-0">
1390
+ <ShieldCheck className="h-[18px] w-[18px] text-emerald-400" />
1391
+ </div>
1392
+ <div>
1393
+ <h1 className="text-xl font-bold text-white tracking-tight">2FA enabled</h1>
1394
+ <p className="text-emerald-400/70 text-[12px]">Save your recovery codes</p>
1320
1395
  </div>
1321
- <button
1322
- onClick={() => {
1323
- navigator.clipboard.writeText(recoveryCodes.join('\n'));
1324
- setRecoveryCodesCopied(true);
1325
- }}
1326
- className={`w-full mt-3 py-2 text-[12px] font-medium rounded-lg transition-colors flex items-center justify-center gap-1.5 ${
1327
- recoveryCodesCopied
1328
- ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'
1329
- : 'bg-white/[0.04] text-white/50 hover:bg-white/[0.08] border border-white/[0.06]'
1330
- }`}
1331
- >
1332
- {recoveryCodesCopied ? (
1333
- <><Check className="h-3.5 w-3.5" />Copied — I've saved these</>
1334
- ) : (
1335
- <><Copy className="h-3.5 w-3.5" />Copy recovery codes</>
1336
- )}
1337
- </button>
1338
1396
  </div>
1339
- </div>
1340
- )}
1341
- </div>
1342
1397
 
1343
- <button
1344
- onClick={next}
1345
- disabled={!canNext}
1346
- className="w-full mt-5 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
1347
- >
1348
- Continue
1349
- <ArrowRight className="h-4 w-4" />
1350
- </button>
1398
+ <p className="text-white/35 text-[13px] mt-3 leading-relaxed">
1399
+ If you lose your authenticator app, you can use one of these codes to sign in. Each code works once. Store them somewhere safe.
1400
+ </p>
1401
+
1402
+ <div className="mt-4 grid grid-cols-2 gap-1.5">
1403
+ {recoveryCodes.map((code, i) => (
1404
+ <div key={i} className="bg-white/[0.03] border border-white/[0.06] rounded-lg px-3 py-2 text-center">
1405
+ <code className="text-[13px] text-white/60 font-mono tracking-wider">{code}</code>
1406
+ </div>
1407
+ ))}
1408
+ </div>
1409
+
1410
+ <button
1411
+ onClick={() => {
1412
+ navigator.clipboard.writeText(recoveryCodes.join('\n'));
1413
+ setRecoveryCodesCopied(true);
1414
+ }}
1415
+ className={`w-full mt-4 py-3 text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 ${
1416
+ recoveryCodesCopied
1417
+ ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'
1418
+ : 'bg-white/[0.05] text-white/60 hover:bg-white/[0.08] border border-white/[0.08]'
1419
+ }`}
1420
+ >
1421
+ {recoveryCodesCopied ? (
1422
+ <><Check className="h-4 w-4" />Copied</>
1423
+ ) : (
1424
+ <><Copy className="h-4 w-4" />Copy recovery codes</>
1425
+ )}
1426
+ </button>
1427
+
1428
+ <button
1429
+ onClick={() => {
1430
+ setRecoveryCodes([]);
1431
+ setStep3Phase('password');
1432
+ }}
1433
+ disabled={!recoveryCodesCopied}
1434
+ className="w-full mt-3 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
1435
+ >
1436
+ Done
1437
+ <ArrowRight className="h-4 w-4" />
1438
+ </button>
1439
+ </motion.div>
1440
+ )}
1441
+ </AnimatePresence>
1351
1442
  </div>
1352
1443
  )}
1353
1444