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.
- package/dist-fluxy/assets/{fluxy-B3uetGlY.js → fluxy-CRSo6Knd.js} +1 -1
- package/dist-fluxy/assets/globals-BWMp2Xbb.js +18 -0
- package/dist-fluxy/assets/globals-C8d9vAbE.css +1 -0
- package/dist-fluxy/assets/{onboard-CLFtmM94.js → onboard-CsZAcwbT.js} +1 -1
- package/dist-fluxy/fluxy.html +3 -3
- package/dist-fluxy/onboard.html +3 -3
- package/package.json +1 -1
- package/supervisor/chat/OnboardWizard.tsx +371 -280
- package/dist-fluxy/assets/globals-C2PnXkaM.js +0 -18
- package/dist-fluxy/assets/globals-SDpY_go3.css +0 -1
|
@@ -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
|
-
<
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
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
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
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
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
if
|
|
1018
|
-
|
|
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
|
-
|
|
1134
|
+
setStep3Phase('totp-setup');
|
|
1021
1135
|
}
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1136
|
+
} else {
|
|
1137
|
+
// Disable (setup incomplete)
|
|
1138
|
+
setTotpEnabled(false);
|
|
1139
|
+
setTotpQrUri('');
|
|
1140
|
+
setTotpSecret('');
|
|
1141
|
+
setTotpOtpauthUri('');
|
|
1026
1142
|
}
|
|
1027
1143
|
}}
|
|
1028
|
-
|
|
1029
|
-
className="
|
|
1144
|
+
type="button"
|
|
1145
|
+
className="w-full flex items-center gap-3 px-4 py-3 text-left"
|
|
1030
1146
|
>
|
|
1031
|
-
|
|
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
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
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
|
-
|
|
1143
|
-
|
|
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
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
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={
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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-
|
|
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/
|
|
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
|
-
|
|
1297
|
+
Or copy secret key manually
|
|
1239
1298
|
</button>
|
|
1240
1299
|
</div>
|
|
1241
1300
|
) : (
|
|
1242
|
-
<div className="flex
|
|
1243
|
-
|
|
1244
|
-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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
|
-
{
|
|
1308
|
-
<div
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
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
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
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
|
|