fluxy-bot 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,30 +1,51 @@
1
- import { useState, type KeyboardEvent } from 'react';
2
- import { Lock, LoaderCircle, ArrowRight } from 'lucide-react';
1
+ import { useState, useRef, useEffect, type KeyboardEvent } from 'react';
2
+ import { Lock, LoaderCircle, ArrowRight, ArrowLeft, Shield, Check } from 'lucide-react';
3
+ import { motion, AnimatePresence } from 'framer-motion';
3
4
 
4
5
  interface Props {
5
6
  onLogin: (token: string) => void;
7
+ totpEnabled?: boolean;
6
8
  }
7
9
 
8
- export default function LoginScreen({ onLogin }: Props) {
10
+ export default function LoginScreen({ onLogin, totpEnabled }: Props) {
9
11
  const [password, setPassword] = useState('');
10
12
  const [error, setError] = useState('');
11
13
  const [loading, setLoading] = useState(false);
12
14
 
15
+ // TOTP state
16
+ const [phase, setPhase] = useState<'password' | 'totp'>('password');
17
+ const [totpCode, setTotpCode] = useState('');
18
+ const [pendingToken, setPendingToken] = useState('');
19
+ const [trustDevice, setTrustDevice] = useState(true);
20
+ const [useRecovery, setUseRecovery] = useState(false);
21
+ const totpInputRef = useRef<HTMLInputElement>(null);
22
+
23
+ // Auto-focus TOTP input when switching to TOTP phase
24
+ useEffect(() => {
25
+ if (phase === 'totp') {
26
+ setTimeout(() => totpInputRef.current?.focus(), 100);
27
+ }
28
+ }, [phase]);
29
+
13
30
  const handleSubmit = async () => {
14
31
  if (!password.trim() || loading) return;
15
32
  setLoading(true);
16
33
  setError('');
17
34
 
18
35
  try {
19
- // Use GET + Basic Auth header (relay proxies don't forward POST bodies)
20
36
  const credentials = btoa(`admin:${password}`);
21
37
  const res = await fetch('/api/portal/login', {
22
38
  headers: { 'Authorization': `Basic ${credentials}` },
39
+ credentials: 'include',
23
40
  });
24
41
  const data = await res.json();
25
42
 
26
43
  if (res.ok && data.token) {
27
44
  onLogin(data.token);
45
+ } else if (res.ok && data.requiresTOTP) {
46
+ setPendingToken(data.pendingToken);
47
+ setPhase('totp');
48
+ setError('');
28
49
  } else {
29
50
  setError(data.error || 'Invalid password');
30
51
  }
@@ -35,8 +56,35 @@ export default function LoginScreen({ onLogin }: Props) {
35
56
  }
36
57
  };
37
58
 
59
+ const handleTotpSubmit = async () => {
60
+ if (!totpCode.trim() || loading) return;
61
+ setLoading(true);
62
+ setError('');
63
+
64
+ try {
65
+ const res = await fetch(
66
+ `/api/portal/login/totp?pending=${encodeURIComponent(pendingToken)}&code=${encodeURIComponent(totpCode)}&trust=${trustDevice ? '1' : '0'}`,
67
+ { credentials: 'include' },
68
+ );
69
+ const data = await res.json();
70
+
71
+ if (res.ok && data.token) {
72
+ onLogin(data.token);
73
+ } else {
74
+ setError(data.error || 'Invalid code');
75
+ }
76
+ } catch {
77
+ setError('Could not reach server');
78
+ } finally {
79
+ setLoading(false);
80
+ }
81
+ };
82
+
38
83
  const handleKeyDown = (e: KeyboardEvent) => {
39
- if (e.key === 'Enter') handleSubmit();
84
+ if (e.key === 'Enter') {
85
+ if (phase === 'password') handleSubmit();
86
+ else handleTotpSubmit();
87
+ }
40
88
  };
41
89
 
42
90
  const inputCls = 'w-full bg-white/[0.05] border border-white/[0.08] text-white rounded-xl px-4 py-3 text-base outline-none input-glow placeholder:text-white/20 transition-all';
@@ -44,45 +92,145 @@ export default function LoginScreen({ onLogin }: Props) {
44
92
  return (
45
93
  <div className="flex flex-col items-center justify-center h-dvh px-6">
46
94
  <div className="w-full max-w-[320px] flex flex-col items-center">
47
- <div className="w-14 h-14 rounded-2xl bg-white/[0.04] border border-white/[0.08] flex items-center justify-center mb-5">
48
- <Lock className="h-6 w-6 text-white/40" />
49
- </div>
50
-
51
- <h1 className="text-xl font-bold text-white tracking-tight mb-1">
52
- Welcome back
53
- </h1>
54
- <p className="text-white/40 text-[13px] mb-6">
55
- Enter your password to continue.
56
- </p>
57
-
58
- {error && (
59
- <div className="w-full bg-red-500/8 border border-red-500/15 rounded-xl px-4 py-2.5 mb-4">
60
- <p className="text-red-400/90 text-[12px]">{error}</p>
61
- </div>
62
- )}
63
-
64
- <input
65
- type="password"
66
- value={password}
67
- onChange={(e) => setPassword(e.target.value)}
68
- onKeyDown={handleKeyDown}
69
- placeholder="Password"
70
- autoFocus
71
- autoComplete="current-password"
72
- className={inputCls}
73
- />
74
-
75
- <button
76
- onClick={handleSubmit}
77
- disabled={!password.trim() || loading}
78
- className="w-full mt-4 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"
79
- >
80
- {loading ? (
81
- <><LoaderCircle className="h-4 w-4 animate-spin" />Signing in...</>
95
+ <AnimatePresence mode="wait">
96
+ {phase === 'password' ? (
97
+ <motion.div
98
+ key="password"
99
+ initial={{ opacity: 0, x: -20 }}
100
+ animate={{ opacity: 1, x: 0 }}
101
+ exit={{ opacity: 0, x: -20 }}
102
+ transition={{ duration: 0.2 }}
103
+ className="w-full flex flex-col items-center"
104
+ >
105
+ <div className="w-14 h-14 rounded-2xl bg-white/[0.04] border border-white/[0.08] flex items-center justify-center mb-5">
106
+ <Lock className="h-6 w-6 text-white/40" />
107
+ </div>
108
+
109
+ <h1 className="text-xl font-bold text-white tracking-tight mb-1">
110
+ Welcome back
111
+ </h1>
112
+ <p className="text-white/40 text-[13px] mb-6">
113
+ Enter your password to continue.
114
+ </p>
115
+
116
+ {error && (
117
+ <div className="w-full bg-red-500/8 border border-red-500/15 rounded-xl px-4 py-2.5 mb-4">
118
+ <p className="text-red-400/90 text-[12px]">{error}</p>
119
+ </div>
120
+ )}
121
+
122
+ <input
123
+ type="password"
124
+ value={password}
125
+ onChange={(e) => setPassword(e.target.value)}
126
+ onKeyDown={handleKeyDown}
127
+ placeholder="Password"
128
+ autoFocus
129
+ autoComplete="current-password"
130
+ className={inputCls}
131
+ />
132
+
133
+ <button
134
+ onClick={handleSubmit}
135
+ disabled={!password.trim() || loading}
136
+ className="w-full mt-4 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"
137
+ >
138
+ {loading ? (
139
+ <><LoaderCircle className="h-4 w-4 animate-spin" />Signing in...</>
140
+ ) : (
141
+ <>Sign In<ArrowRight className="h-4 w-4" /></>
142
+ )}
143
+ </button>
144
+ </motion.div>
82
145
  ) : (
83
- <>Sign In<ArrowRight className="h-4 w-4" /></>
146
+ <motion.div
147
+ key="totp"
148
+ initial={{ opacity: 0, x: 20 }}
149
+ animate={{ opacity: 1, x: 0 }}
150
+ exit={{ opacity: 0, x: 20 }}
151
+ transition={{ duration: 0.2 }}
152
+ className="w-full flex flex-col items-center"
153
+ >
154
+ <div className="w-14 h-14 rounded-2xl bg-[#AF27E3]/10 border border-[#AF27E3]/20 flex items-center justify-center mb-5">
155
+ <Shield className="h-6 w-6 text-[#AF27E3]" />
156
+ </div>
157
+
158
+ <h1 className="text-xl font-bold text-white tracking-tight mb-1">
159
+ {useRecovery ? 'Recovery code' : 'Enter your 2FA code'}
160
+ </h1>
161
+ <p className="text-white/40 text-[13px] mb-6">
162
+ {useRecovery
163
+ ? 'Enter one of your recovery codes.'
164
+ : 'Open your authenticator app and enter the 6-digit code.'}
165
+ </p>
166
+
167
+ {error && (
168
+ <div className="w-full bg-red-500/8 border border-red-500/15 rounded-xl px-4 py-2.5 mb-4">
169
+ <p className="text-red-400/90 text-[12px]">{error}</p>
170
+ </div>
171
+ )}
172
+
173
+ <input
174
+ ref={totpInputRef}
175
+ type="text"
176
+ inputMode={useRecovery ? 'text' : 'numeric'}
177
+ autoComplete="one-time-code"
178
+ maxLength={useRecovery ? 20 : 6}
179
+ value={totpCode}
180
+ onChange={(e) => {
181
+ setTotpCode(useRecovery ? e.target.value : e.target.value.replace(/\D/g, ''));
182
+ setError('');
183
+ }}
184
+ onKeyDown={handleKeyDown}
185
+ placeholder={useRecovery ? 'Recovery code' : '000000'}
186
+ className={inputCls + (useRecovery ? '' : ' tracking-[0.3em] text-center font-mono')}
187
+ />
188
+
189
+ {/* Trust device checkbox */}
190
+ <label className="flex items-center gap-2.5 mt-4 w-full cursor-pointer">
191
+ <div
192
+ onClick={() => setTrustDevice(v => !v)}
193
+ className={`w-5 h-5 rounded-md border flex items-center justify-center transition-all ${
194
+ trustDevice
195
+ ? 'bg-[#AF27E3] border-[#AF27E3]'
196
+ : 'bg-white/[0.04] border-white/[0.12]'
197
+ }`}
198
+ >
199
+ {trustDevice && <Check className="h-3.5 w-3.5 text-white" />}
200
+ </div>
201
+ <span className="text-[13px] text-white/50">Trust this device for 90 days</span>
202
+ </label>
203
+
204
+ <button
205
+ onClick={handleTotpSubmit}
206
+ disabled={!totpCode.trim() || loading}
207
+ className="w-full mt-4 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"
208
+ >
209
+ {loading ? (
210
+ <><LoaderCircle className="h-4 w-4 animate-spin" />Verifying...</>
211
+ ) : (
212
+ <>Verify<ArrowRight className="h-4 w-4" /></>
213
+ )}
214
+ </button>
215
+
216
+ <div className="flex items-center gap-4 mt-4">
217
+ <button
218
+ onClick={() => { setPhase('password'); setError(''); setTotpCode(''); setUseRecovery(false); }}
219
+ className="text-[12px] text-white/30 hover:text-white/50 flex items-center gap-1 transition-colors"
220
+ >
221
+ <ArrowLeft className="h-3 w-3" />
222
+ Back
223
+ </button>
224
+ <button
225
+ onClick={() => { setUseRecovery(v => !v); setTotpCode(''); setError(''); }}
226
+ className="text-[12px] text-white/30 hover:text-white/50 transition-colors"
227
+ >
228
+ {useRecovery ? 'Use authenticator code' : 'Use a recovery code'}
229
+ </button>
230
+ </div>
231
+ </motion.div>
84
232
  )}
85
- </button>
233
+ </AnimatePresence>
86
234
  </div>
87
235
  </div>
88
236
  );
@@ -198,6 +198,12 @@ export async function startSupervisor() {
198
198
  'POST /api/auth/codex/start',
199
199
  'POST /api/auth/codex/cancel',
200
200
  'GET /api/auth/codex/status',
201
+ 'POST /api/portal/totp/setup',
202
+ 'POST /api/portal/totp/verify-setup',
203
+ 'POST /api/portal/totp/disable',
204
+ 'GET /api/portal/totp/status',
205
+ 'GET /api/portal/login/totp',
206
+ 'POST /api/portal/devices/revoke',
201
207
  ];
202
208
 
203
209
  function isExemptRoute(method: string, url: string): boolean {
package/worker/db.ts CHANGED
@@ -39,6 +39,15 @@ CREATE TABLE IF NOT EXISTS push_subscriptions (
39
39
  keys_auth TEXT NOT NULL,
40
40
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
41
41
  );
42
+ CREATE TABLE IF NOT EXISTS trusted_devices (
43
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
44
+ token TEXT NOT NULL UNIQUE,
45
+ label TEXT,
46
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
47
+ expires_at DATETIME NOT NULL,
48
+ last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
49
+ );
50
+ CREATE INDEX IF NOT EXISTS idx_td_token ON trusted_devices(token);
42
51
  `;
43
52
 
44
53
  let db: Database.Database;
@@ -145,6 +154,29 @@ export function getPushSubscriptionByEndpoint(endpoint: string) {
145
154
  return db.prepare('SELECT * FROM push_subscriptions WHERE endpoint = ?').get(endpoint) as { id: number; endpoint: string; keys_p256dh: string; keys_auth: string } | undefined;
146
155
  }
147
156
 
157
+ // Trusted devices (2FA)
158
+ export function createTrustedDevice(token: string, label: string, expiresAt: string) {
159
+ return db.prepare('INSERT INTO trusted_devices (token, label, expires_at) VALUES (?, ?, ?) RETURNING *').get(token, label, expiresAt) as any;
160
+ }
161
+ export function getTrustedDevice(token: string): { id: string; token: string; label: string; created_at: string; expires_at: string; last_seen: string } | undefined {
162
+ return db.prepare("SELECT * FROM trusted_devices WHERE token = ? AND expires_at > datetime('now')").get(token) as any;
163
+ }
164
+ export function updateDeviceLastSeen(token: string) {
165
+ db.prepare("UPDATE trusted_devices SET last_seen = CURRENT_TIMESTAMP WHERE token = ?").run(token);
166
+ }
167
+ export function listTrustedDevices() {
168
+ return db.prepare("SELECT id, label, last_seen, created_at FROM trusted_devices WHERE expires_at > datetime('now') ORDER BY last_seen DESC").all() as { id: string; label: string; last_seen: string; created_at: string }[];
169
+ }
170
+ export function deleteTrustedDevice(id: string) {
171
+ db.prepare('DELETE FROM trusted_devices WHERE id = ?').run(id);
172
+ }
173
+ export function deleteExpiredDevices() {
174
+ db.prepare("DELETE FROM trusted_devices WHERE expires_at <= datetime('now')").run();
175
+ }
176
+ export function deleteAllTrustedDevices() {
177
+ db.prepare('DELETE FROM trusted_devices').run();
178
+ }
179
+
148
180
  // Recent messages (for context injection)
149
181
  export function getRecentMessages(convId: string, limit = 20) {
150
182
  return db.prepare(`
package/worker/index.ts CHANGED
@@ -5,8 +5,10 @@ import path from 'path';
5
5
  import { loadConfig, saveConfig } from '../shared/config.js';
6
6
  import { paths, WORKSPACE_DIR } from '../shared/paths.js';
7
7
  import { log } from '../shared/logger.js';
8
- import { initDb, closeDb, listConversations, createConversation, deleteConversation, getMessages, addMessage, getSetting, getAllSettings, setSetting, createSession, getSession, deleteExpiredSessions, getRecentMessages, getMessagesBefore, addPushSubscription, removePushSubscription, getAllPushSubscriptions, getPushSubscriptionByEndpoint } from './db.js';
8
+ import { initDb, closeDb, listConversations, createConversation, deleteConversation, getMessages, addMessage, getSetting, getAllSettings, setSetting, createSession, getSession, deleteExpiredSessions, getRecentMessages, getMessagesBefore, addPushSubscription, removePushSubscription, getAllPushSubscriptions, getPushSubscriptionByEndpoint, createTrustedDevice, getTrustedDevice, updateDeviceLastSeen, listTrustedDevices, deleteTrustedDevice, deleteExpiredDevices, deleteAllTrustedDevices } from './db.js';
9
9
  import webpush from 'web-push';
10
+ import { TOTP } from 'otpauth';
11
+ import QRCode from 'qrcode';
10
12
  import { startCodexOAuth, cancelCodexOAuth, getCodexAuthStatus, readCodexAccessToken } from './codex-auth.js';
11
13
  import { startClaudeOAuth, exchangeClaudeCode, getClaudeAuthStatus, readClaudeAccessToken } from './claude-auth.js';
12
14
  import { checkAvailability, registerHandle, releaseHandle, updateTunnelUrl, startHeartbeat, stopHeartbeat } from '../shared/relay.js';
@@ -26,6 +28,45 @@ function verifyPassword(password: string, stored: string): boolean {
26
28
  return hash === test;
27
29
  }
28
30
 
31
+ // ── TOTP helpers ──
32
+
33
+ function generateTOTPSecret(): string {
34
+ return crypto.randomBytes(20).toString('base64url').replace(/[^A-Z2-7]/gi, '').slice(0, 32).toUpperCase();
35
+ }
36
+
37
+ function verifyTOTPCode(code: string, secret: string): boolean {
38
+ const totp = new TOTP({ issuer: 'Fluxy', algorithm: 'SHA1', digits: 6, period: 30, secret });
39
+ const delta = totp.validate({ token: code, window: 1 });
40
+ return delta !== null;
41
+ }
42
+
43
+ function generateRecoveryCodes(): string[] {
44
+ const codes: string[] = [];
45
+ for (let i = 0; i < 8; i++) {
46
+ codes.push(crypto.randomBytes(4).toString('hex'));
47
+ }
48
+ return codes;
49
+ }
50
+
51
+ function hashRecoveryCode(code: string): string {
52
+ return crypto.createHash('sha256').update(code.toLowerCase()).digest('hex');
53
+ }
54
+
55
+ function verifyRecoveryCode(code: string, hashes: string[]): { valid: boolean; remaining: string[] } {
56
+ const h = hashRecoveryCode(code);
57
+ const idx = hashes.indexOf(h);
58
+ if (idx === -1) return { valid: false, remaining: hashes };
59
+ const remaining = [...hashes];
60
+ remaining.splice(idx, 1);
61
+ return { valid: true, remaining };
62
+ }
63
+
64
+ function parseCookie(cookieHeader: string | undefined, name: string): string | undefined {
65
+ if (!cookieHeader) return undefined;
66
+ const match = cookieHeader.split(';').map(c => c.trim()).find(c => c.startsWith(`${name}=`));
67
+ return match ? match.slice(name.length + 1) : undefined;
68
+ }
69
+
29
70
  const port = parseInt(process.env.WORKER_PORT || '3001', 10);
30
71
  const config = loadConfig();
31
72
 
@@ -293,6 +334,7 @@ app.get('/api/onboard/status', (_, res) => {
293
334
  tunnelMode: cfg.tunnel?.mode || 'quick',
294
335
  tunnelDomain: cfg.tunnel?.domain || '',
295
336
  tunnelUrl: cfg.tunnelUrl || '',
337
+ totpEnabled: settings.totp_enabled === 'true',
296
338
  });
297
339
  });
298
340
 
@@ -304,12 +346,38 @@ app.post('/api/portal/verify-password', (req, res) => {
304
346
  });
305
347
 
306
348
  // Shared login logic (used by both POST and GET handlers)
307
- function handleLogin(username: string | undefined, password: string | undefined, res: any) {
349
+ function handleLogin(username: string | undefined, password: string | undefined, req: any, res: any) {
308
350
  if (!password) { res.status(400).json({ error: 'Password required' }); return; }
309
351
  const storedPass = getSetting('portal_pass');
310
352
  if (!storedPass) { res.status(400).json({ error: 'No password set' }); return; }
311
353
  if (!verifyPassword(password, storedPass)) { res.status(401).json({ error: 'Invalid password' }); return; }
312
354
 
355
+ // Check if TOTP is enabled
356
+ const totpEnabled = getSetting('totp_enabled') === 'true';
357
+ if (totpEnabled) {
358
+ // Check for trusted device cookie
359
+ const deviceToken = parseCookie(req.headers.cookie, 'fluxy_device');
360
+ if (deviceToken) {
361
+ const device = getTrustedDevice(deviceToken);
362
+ if (device) {
363
+ updateDeviceLastSeen(deviceToken);
364
+ // Trusted device — skip TOTP
365
+ deleteExpiredSessions();
366
+ const token = crypto.randomBytes(64).toString('hex');
367
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
368
+ createSession(token, expiresAt);
369
+ res.json({ token, expiresAt });
370
+ return;
371
+ }
372
+ }
373
+ // No valid trusted device — require TOTP
374
+ const pendingToken = crypto.randomBytes(32).toString('hex');
375
+ const pendingExpiry = new Date(Date.now() + 5 * 60 * 1000).toISOString();
376
+ setSetting(`totp_pending_login:${pendingToken}`, pendingExpiry);
377
+ res.json({ requiresTOTP: true, pendingToken });
378
+ return;
379
+ }
380
+
313
381
  deleteExpiredSessions();
314
382
  const token = crypto.randomBytes(64).toString('hex');
315
383
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
@@ -320,7 +388,7 @@ function handleLogin(username: string | undefined, password: string | undefined,
320
388
  // POST: credentials in JSON body
321
389
  app.post('/api/portal/login', (req, res) => {
322
390
  const { username, password } = req.body;
323
- handleLogin(username, password, res);
391
+ handleLogin(username, password, req, res);
324
392
  });
325
393
 
326
394
  // GET: credentials via Authorization Basic header (relay proxies don't forward POST bodies)
@@ -330,7 +398,7 @@ app.get('/api/portal/login', (req, res) => {
330
398
  const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
331
399
  const sep = decoded.indexOf(':');
332
400
  if (sep < 0) { res.status(400).json({ error: 'Invalid credentials format' }); return; }
333
- handleLogin(decoded.slice(0, sep), decoded.slice(sep + 1), res);
401
+ handleLogin(decoded.slice(0, sep), decoded.slice(sep + 1), req, res);
334
402
  });
335
403
 
336
404
  // POST + GET for validate-token (same relay issue)
@@ -348,6 +416,185 @@ app.get('/api/portal/validate-token', (req, res) => {
348
416
  handleValidateToken(req.query.token as string, res);
349
417
  });
350
418
 
419
+ // ── TOTP 2FA endpoints ──
420
+
421
+ app.get('/api/portal/totp/status', (_req, res) => {
422
+ res.json({ enabled: getSetting('totp_enabled') === 'true' });
423
+ });
424
+
425
+ app.post('/api/portal/totp/setup', async (req, res) => {
426
+ // Verify caller has auth: either valid session token or correct password
427
+ const authHeader = req.headers['authorization'];
428
+ let authorized = false;
429
+ if (authHeader?.startsWith('Bearer ')) {
430
+ const session = getSession(authHeader.slice(7));
431
+ if (session) authorized = true;
432
+ }
433
+ if (!authorized && req.body?.password) {
434
+ const storedPass = getSetting('portal_pass');
435
+ if (storedPass && verifyPassword(req.body.password, storedPass)) authorized = true;
436
+ }
437
+ if (!authorized) { res.status(401).json({ error: 'Unauthorized' }); return; }
438
+
439
+ const secret = generateTOTPSecret();
440
+ setSetting('totp_pending_secret', secret);
441
+
442
+ const botName = getSetting('agent_name') || 'Fluxy';
443
+ const totp = new TOTP({ issuer: 'Fluxy', label: botName, algorithm: 'SHA1', digits: 6, period: 30, secret });
444
+ const otpauthUri = totp.toString();
445
+
446
+ try {
447
+ const qrDataUri = await QRCode.toDataURL(otpauthUri, { width: 256, margin: 2 });
448
+ res.json({ secret, qrDataUri, otpauthUri });
449
+ } catch (err: any) {
450
+ res.status(500).json({ error: 'Failed to generate QR code' });
451
+ }
452
+ });
453
+
454
+ app.post('/api/portal/totp/verify-setup', (req, res) => {
455
+ const authHeader = req.headers['authorization'];
456
+ let authorized = false;
457
+ if (authHeader?.startsWith('Bearer ')) {
458
+ const session = getSession(authHeader.slice(7));
459
+ if (session) authorized = true;
460
+ }
461
+ if (!authorized && req.body?.password) {
462
+ const storedPass = getSetting('portal_pass');
463
+ if (storedPass && verifyPassword(req.body.password, storedPass)) authorized = true;
464
+ }
465
+ if (!authorized) { res.status(401).json({ error: 'Unauthorized' }); return; }
466
+
467
+ const { code } = req.body;
468
+ if (!code) { res.status(400).json({ error: 'Code required' }); return; }
469
+
470
+ const pendingSecret = getSetting('totp_pending_secret');
471
+ if (!pendingSecret) { res.status(400).json({ error: 'No TOTP setup in progress' }); return; }
472
+
473
+ if (!verifyTOTPCode(code, pendingSecret)) {
474
+ res.status(400).json({ error: 'Invalid code. Check your authenticator app and try again.' });
475
+ return;
476
+ }
477
+
478
+ // Success: persist TOTP config
479
+ setSetting('totp_secret', pendingSecret);
480
+ setSetting('totp_enabled', 'true');
481
+
482
+ // Generate recovery codes
483
+ const codes = generateRecoveryCodes();
484
+ const hashes = codes.map(hashRecoveryCode);
485
+ setSetting('totp_recovery_codes', JSON.stringify(hashes));
486
+
487
+ // Clean up pending secret
488
+ setSetting('totp_pending_secret', '');
489
+
490
+ res.json({ success: true, recoveryCodes: codes });
491
+ });
492
+
493
+ app.post('/api/portal/totp/disable', (req, res) => {
494
+ const { password, code } = req.body;
495
+ if (!password || !code) { res.status(400).json({ error: 'Password and TOTP code required' }); return; }
496
+
497
+ const storedPass = getSetting('portal_pass');
498
+ if (!storedPass || !verifyPassword(password, storedPass)) {
499
+ res.status(401).json({ error: 'Invalid password' });
500
+ return;
501
+ }
502
+
503
+ const secret = getSetting('totp_secret');
504
+ if (!secret) { res.status(400).json({ error: '2FA is not enabled' }); return; }
505
+
506
+ if (!verifyTOTPCode(code, secret)) {
507
+ res.status(400).json({ error: 'Invalid TOTP code' });
508
+ return;
509
+ }
510
+
511
+ // Clear all TOTP settings
512
+ setSetting('totp_enabled', 'false');
513
+ setSetting('totp_secret', '');
514
+ setSetting('totp_recovery_codes', '');
515
+ setSetting('totp_pending_secret', '');
516
+ deleteAllTrustedDevices();
517
+
518
+ res.json({ success: true });
519
+ });
520
+
521
+ app.get('/api/portal/login/totp', (req, res) => {
522
+ const pending = req.query.pending as string;
523
+ const code = req.query.code as string;
524
+ const trust = req.query.trust as string;
525
+
526
+ if (!pending || !code) { res.status(400).json({ error: 'Missing pending token or code' }); return; }
527
+
528
+ // Validate pending token
529
+ const expiry = getSetting(`totp_pending_login:${pending}`);
530
+ if (!expiry || new Date(expiry) < new Date()) {
531
+ res.status(401).json({ error: 'Login session expired. Please start over.' });
532
+ return;
533
+ }
534
+
535
+ // Clean up pending token
536
+ setSetting(`totp_pending_login:${pending}`, '');
537
+
538
+ const secret = getSetting('totp_secret');
539
+ if (!secret) { res.status(400).json({ error: '2FA is not configured' }); return; }
540
+
541
+ // Try TOTP code first
542
+ let valid = verifyTOTPCode(code, secret);
543
+
544
+ // If not valid as TOTP, try as recovery code
545
+ if (!valid) {
546
+ const hashesJson = getSetting('totp_recovery_codes');
547
+ if (hashesJson) {
548
+ try {
549
+ const hashes = JSON.parse(hashesJson) as string[];
550
+ const result = verifyRecoveryCode(code, hashes);
551
+ if (result.valid) {
552
+ valid = true;
553
+ setSetting('totp_recovery_codes', JSON.stringify(result.remaining));
554
+ }
555
+ } catch {}
556
+ }
557
+ }
558
+
559
+ if (!valid) {
560
+ res.status(401).json({ error: 'Invalid code' });
561
+ return;
562
+ }
563
+
564
+ // Create session
565
+ deleteExpiredSessions();
566
+ const token = crypto.randomBytes(64).toString('hex');
567
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
568
+ createSession(token, expiresAt);
569
+
570
+ // Trust device if requested
571
+ if (trust === '1') {
572
+ deleteExpiredDevices();
573
+ const deviceToken = crypto.randomBytes(32).toString('hex');
574
+ const deviceExpiry = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
575
+ createTrustedDevice(deviceToken, 'Browser', deviceExpiry);
576
+ res.setHeader('Set-Cookie', `fluxy_device=${deviceToken}; HttpOnly; Secure; SameSite=Strict; Max-Age=7776000; Path=/`);
577
+ }
578
+
579
+ res.json({ token, expiresAt });
580
+ });
581
+
582
+ app.get('/api/portal/devices', (_req, res) => {
583
+ res.json(listTrustedDevices());
584
+ });
585
+
586
+ app.delete('/api/portal/devices/:id', (req, res) => {
587
+ deleteTrustedDevice(req.params.id);
588
+ res.json({ ok: true });
589
+ });
590
+
591
+ app.post('/api/portal/devices/revoke', (req, res) => {
592
+ const { id } = req.body;
593
+ if (!id) { res.status(400).json({ error: 'Device ID required' }); return; }
594
+ deleteTrustedDevice(id);
595
+ res.json({ ok: true });
596
+ });
597
+
351
598
  app.post('/api/onboard', (req, res) => {
352
599
  const { userName, agentName, provider, model, apiKey, baseUrl, portalUser, portalPass, whisperEnabled, whisperKey } = req.body;
353
600