@riligar/elysia-backup 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,18 +45,18 @@ console.log('Backup UI at http://localhost:3000/backup')
45
45
 
46
46
  ## Configuration
47
47
 
48
- | Option | Type | Required | Description |
49
- | ----------------- | -------- | -------- | ------------------------------------------------------------- |
50
- | `bucket` | string | ✅ | R2/S3 bucket name |
51
- | `accessKeyId` | string | ✅ | R2/S3 Access Key ID |
52
- | `secretAccessKey` | string | ✅ | R2/S3 Secret Access Key |
53
- | `endpoint` | string | ✅ | R2/S3 Endpoint URL |
54
- | `sourceDir` | string | ✅ | Local directory to backup |
55
- | `prefix` | string | ❌ | Prefix for S3 keys (e.g., 'backups/') |
56
- | `extensions` | string[] | ❌ | File extensions to include |
57
- | `cronSchedule` | string | ❌ | Cron expression for scheduled backups |
58
- | `cronEnabled` | boolean | ❌ | Enable/disable scheduled backups |
59
- | `configPath` | string | ❌ | Path to save runtime config (default: './backup-config.json') |
48
+ | Option | Type | Required | Description |
49
+ | ----------------- | -------- | -------- | ------------------------------------------------------ |
50
+ | `bucket` | string | ✅ | R2/S3 bucket name |
51
+ | `accessKeyId` | string | ✅ | R2/S3 Access Key ID |
52
+ | `secretAccessKey` | string | ✅ | R2/S3 Secret Access Key |
53
+ | `endpoint` | string | ✅ | R2/S3 Endpoint URL |
54
+ | `sourceDir` | string | ✅ | Local directory to backup |
55
+ | `prefix` | string | ❌ | Prefix for S3 keys (e.g., 'backups/') |
56
+ | `extensions` | string[] | ❌ | File extensions to include |
57
+ | `cronSchedule` | string | ❌ | Cron expression for scheduled backups |
58
+ | `cronEnabled` | boolean | ❌ | Enable/disable scheduled backups |
59
+ | `configPath` | string | ❌ | Path to save runtime config (default: './config.json') |
60
60
 
61
61
  ## API Endpoints
62
62
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@riligar/elysia-backup",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Elysia plugin for R2/S3 backup with a built-in UI dashboard",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -46,7 +46,9 @@
46
46
  "@elysiajs/html": ">=1.0.0"
47
47
  },
48
48
  "dependencies": {
49
- "cron": "^3.5.0"
49
+ "cron": "^3.5.0",
50
+ "otplib": "^12.0.1",
51
+ "qrcode": "^1.5.4"
50
52
  },
51
53
  "devDependencies": {
52
54
  "@elysiajs/html": "^1.4.0",
@@ -3,6 +3,8 @@
3
3
  import { Elysia, t } from 'elysia'
4
4
  import { S3Client } from 'bun'
5
5
  import { CronJob } from 'cron'
6
+ import { authenticator } from 'otplib'
7
+ import QRCode from 'qrcode'
6
8
  import { readdir, stat, readFile, writeFile, mkdir } from 'node:fs/promises'
7
9
  import { readFileSync } from 'node:fs'
8
10
  import { existsSync } from 'node:fs'
@@ -66,11 +68,11 @@ const deleteSession = token => {
66
68
  * @param {string} [config.prefix] - Optional prefix for S3 keys (e.g. 'backups/')
67
69
  * @param {string} [config.cronSchedule] - Cron schedule expression
68
70
  * @param {boolean} [config.cronEnabled] - Whether the cron schedule is enabled
69
- * @param {string} [config.configPath] - Path to save runtime configuration (default: './backup-config.json')
71
+ * @param {string} [config.configPath] - Path to save runtime configuration (default: './config.json')
70
72
  */
71
73
  export const r2Backup = initialConfig => app => {
72
74
  // State to hold runtime configuration (allows UI updates)
73
- const configPath = initialConfig.configPath || './backup-config.json'
75
+ const configPath = initialConfig.configPath || './config.json'
74
76
 
75
77
  // Load saved config if exists
76
78
  let savedConfig = {}
@@ -430,6 +432,30 @@ export const r2Backup = initialConfig => app => {
430
432
  </div>
431
433
  </div>
432
434
 
435
+ <!-- TOTP Code (only shown when TOTP is enabled) -->
436
+ <div x-show="totpEnabled" x-cloak>
437
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Authenticator Code</label>
438
+ <div class="relative">
439
+ <div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
440
+ <i data-lucide="smartphone" class="w-5 h-5 text-gray-400"></i>
441
+ </div>
442
+ <input
443
+ type="text"
444
+ x-model="totpCode"
445
+ inputmode="numeric"
446
+ pattern="[0-9]*"
447
+ maxlength="6"
448
+ :required="totpEnabled"
449
+ class="w-full bg-gray-50 border border-gray-200 rounded-lg pl-12 pr-4 py-3 text-gray-900 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none transition-all font-medium tracking-widest text-center text-lg"
450
+ placeholder="000000"
451
+ >
452
+ </div>
453
+ <p class="text-xs text-gray-500 mt-2 flex items-center gap-1">
454
+ <i data-lucide="info" class="w-3 h-3"></i>
455
+ Enter the 6-digit code from your authenticator app
456
+ </p>
457
+ </div>
458
+
433
459
  <!-- Submit Button -->
434
460
  <button
435
461
  type="submit"
@@ -461,6 +487,8 @@ export const r2Backup = initialConfig => app => {
461
487
  Alpine.data('loginApp', () => ({
462
488
  username: '',
463
489
  password: '',
490
+ totpCode: '',
491
+ totpEnabled: ${config.auth?.totpSecret ? 'true' : 'false'},
464
492
  loading: false,
465
493
  error: '',
466
494
 
@@ -473,13 +501,19 @@ export const r2Backup = initialConfig => app => {
473
501
  this.error = '';
474
502
 
475
503
  try {
504
+ const payload = {
505
+ username: this.username,
506
+ password: this.password
507
+ };
508
+
509
+ if (this.totpEnabled && this.totpCode) {
510
+ payload.totpCode = this.totpCode;
511
+ }
512
+
476
513
  const response = await fetch('/backup/auth/login', {
477
514
  method: 'POST',
478
515
  headers: { 'Content-Type': 'application/json' },
479
- body: JSON.stringify({
480
- username: this.username,
481
- password: this.password
482
- })
516
+ body: JSON.stringify(payload)
483
517
  });
484
518
 
485
519
  const data = await response.json();
@@ -520,10 +554,24 @@ export const r2Backup = initialConfig => app => {
520
554
  }
521
555
  }
522
556
 
523
- const { username, password } = body
557
+ const { username, password, totpCode } = body
524
558
 
525
559
  // Validate credentials
526
560
  if (username === config.auth.username && password === config.auth.password) {
561
+ // Validate TOTP if configured
562
+ if (config.auth.totpSecret) {
563
+ if (!totpCode) {
564
+ set.status = 401
565
+ return { status: 'error', message: 'Authenticator code is required' }
566
+ }
567
+
568
+ const isValidTotp = authenticator.check(totpCode, config.auth.totpSecret)
569
+ if (!isValidTotp) {
570
+ set.status = 401
571
+ return { status: 'error', message: 'Invalid authenticator code' }
572
+ }
573
+ }
574
+
527
575
  // Create session
528
576
  const sessionDuration = config.auth.sessionDuration || 24 * 60 * 60 * 1000 // 24h default
529
577
  const { token, expiresAt } = createSession(username, sessionDuration)
@@ -542,6 +590,7 @@ export const r2Backup = initialConfig => app => {
542
590
  body: t.Object({
543
591
  username: t.String(),
544
592
  password: t.String(),
593
+ totpCode: t.Optional(t.String()),
545
594
  }),
546
595
  }
547
596
  )
@@ -562,6 +611,105 @@ export const r2Backup = initialConfig => app => {
562
611
  return { status: 'success', message: 'Logged out successfully' }
563
612
  })
564
613
 
614
+ // TOTP: Get Status
615
+ .get('/api/totp/status', () => {
616
+ return {
617
+ enabled: !!config.auth?.totpSecret,
618
+ }
619
+ })
620
+
621
+ // TOTP: Generate new secret and QR code
622
+ .post('/api/totp/generate', async () => {
623
+ const secret = authenticator.generateSecret()
624
+ const serviceName = config.serviceName || 'Backup Manager'
625
+ const accountName = config.auth?.username || 'admin'
626
+
627
+ const otpauth = authenticator.keyuri(accountName, serviceName, secret)
628
+ const qrCodeDataUrl = await QRCode.toDataURL(otpauth)
629
+
630
+ return {
631
+ status: 'success',
632
+ secret,
633
+ qrCode: qrCodeDataUrl,
634
+ otpauth,
635
+ }
636
+ })
637
+
638
+ // TOTP: Verify and save
639
+ .post(
640
+ '/api/totp/verify',
641
+ async ({ body, set }) => {
642
+ const { secret, code } = body
643
+
644
+ // Verify the code is valid
645
+ const isValid = authenticator.check(code, secret)
646
+
647
+ if (!isValid) {
648
+ set.status = 400
649
+ return { status: 'error', message: 'Invalid code. Please try again.' }
650
+ }
651
+
652
+ // Save the secret to config
653
+ config.auth = config.auth || {}
654
+ config.auth.totpSecret = secret
655
+
656
+ // Persist config
657
+ try {
658
+ await writeFile(configPath, JSON.stringify(config, null, 2))
659
+ } catch (e) {
660
+ console.error('Failed to save TOTP config:', e)
661
+ set.status = 500
662
+ return { status: 'error', message: 'Failed to save configuration' }
663
+ }
664
+
665
+ return { status: 'success', message: 'Two-factor authentication enabled successfully' }
666
+ },
667
+ {
668
+ body: t.Object({
669
+ secret: t.String(),
670
+ code: t.String(),
671
+ }),
672
+ }
673
+ )
674
+
675
+ // TOTP: Disable
676
+ .post(
677
+ '/api/totp/disable',
678
+ async ({ body, set }) => {
679
+ const { code } = body
680
+
681
+ // Require valid TOTP code to disable
682
+ if (config.auth?.totpSecret) {
683
+ const isValid = authenticator.check(code, config.auth.totpSecret)
684
+ if (!isValid) {
685
+ set.status = 400
686
+ return { status: 'error', message: 'Invalid code. Please enter your current authenticator code.' }
687
+ }
688
+ }
689
+
690
+ // Remove TOTP secret from config
691
+ if (config.auth) {
692
+ delete config.auth.totpSecret
693
+ }
694
+
695
+ // Persist config
696
+ try {
697
+ await writeFile(configPath, JSON.stringify(config, null, 2))
698
+ } catch (e) {
699
+ console.error('Failed to save config:', e)
700
+ set.status = 500
701
+ return { status: 'error', message: 'Failed to save configuration' }
702
+ }
703
+
704
+ return { status: 'success', message: 'Two-factor authentication disabled' }
705
+ },
706
+ {
707
+ body: t.Object({
708
+ code: t.String(),
709
+ }),
710
+ }
711
+ )
712
+
565
713
  // API: Run Backup
566
714
  .post(
567
715
  '/api/run',
@@ -777,7 +925,7 @@ export const r2Backup = initialConfig => app => {
777
925
  </div>
778
926
 
779
927
  ${
780
- config.auth && config.auth.enabled
928
+ config.auth && config.auth.username && config.auth.password
781
929
  ? `
782
930
  <!-- Logout Button -->
783
931
  <button @click="logout" class="inline-flex items-center gap-2 px-4 py-2.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-semibold text-sm rounded-lg transition-all">
@@ -1085,6 +1233,168 @@ export const r2Backup = initialConfig => app => {
1085
1233
  </div>
1086
1234
  </form>
1087
1235
  </div>
1236
+
1237
+ <!-- Security Section -->
1238
+ <div class="bg-white rounded-2xl border border-gray-100 shadow-[0_2px_8px_rgba(0,0,0,0.04)] p-10 mt-8">
1239
+ <h2 class="text-xl font-bold text-gray-900 mb-8 flex items-center gap-2">
1240
+ <i data-lucide="shield" class="w-5 h-5"></i>
1241
+ Security
1242
+ </h2>
1243
+
1244
+ <!-- TOTP Setup -->
1245
+ <div class="space-y-6">
1246
+ <div class="flex items-start justify-between">
1247
+ <div>
1248
+ <h3 class="font-semibold text-gray-900 flex items-center gap-2">
1249
+ <i data-lucide="smartphone" class="w-4 h-4"></i>
1250
+ Two-Factor Authentication (2FA)
1251
+ </h3>
1252
+ <p class="text-sm text-gray-500 mt-1">
1253
+ Add an extra layer of security using an authenticator app
1254
+ </p>
1255
+ </div>
1256
+ <div x-show="!totpEnabled && !showTotpSetup">
1257
+ <button
1258
+ @click="generateTotp()"
1259
+ class="bg-gray-900 hover:bg-gray-800 text-white font-bold py-2.5 px-5 rounded-lg transition-all flex items-center gap-2"
1260
+ >
1261
+ <i data-lucide="plus" class="w-4 h-4"></i>
1262
+ Enable 2FA
1263
+ </button>
1264
+ </div>
1265
+ <div x-show="totpEnabled && !showTotpSetup">
1266
+ <span class="inline-flex items-center gap-2 px-4 py-2 bg-green-100 text-green-800 rounded-lg font-semibold text-sm">
1267
+ <i data-lucide="check-circle" class="w-4 h-4"></i>
1268
+ Enabled
1269
+ </span>
1270
+ </div>
1271
+ </div>
1272
+
1273
+ <!-- TOTP Setup Flow -->
1274
+ <div x-show="showTotpSetup" x-cloak class="border-t border-gray-100 pt-6 mt-6">
1275
+ <!-- Loading -->
1276
+ <div x-show="totpLoading" class="text-center py-8">
1277
+ <i data-lucide="loader-2" class="w-8 h-8 animate-spin text-gray-400 mx-auto"></i>
1278
+ <p class="text-sm text-gray-500 mt-2">Generating secure key...</p>
1279
+ </div>
1280
+
1281
+ <!-- QR Code Display -->
1282
+ <div x-show="!totpLoading && totpQrCode" class="space-y-6">
1283
+ <div class="bg-gray-50 rounded-xl p-6 text-center">
1284
+ <p class="text-sm font-medium text-gray-700 mb-4">
1285
+ Scan this QR code with your authenticator app:
1286
+ </p>
1287
+ <img :src="totpQrCode" alt="TOTP QR Code" class="mx-auto w-48 h-48 rounded-lg shadow-sm">
1288
+
1289
+ <div class="mt-4 text-xs text-gray-500">
1290
+ <p class="mb-2">Or enter this code manually:</p>
1291
+ <code class="bg-white px-3 py-1.5 rounded border border-gray-200 font-mono text-gray-800 select-all" x-text="totpSecret"></code>
1292
+ </div>
1293
+ </div>
1294
+
1295
+ <!-- Verification -->
1296
+ <div class="space-y-4">
1297
+ <label class="block text-sm font-semibold text-gray-700">
1298
+ Enter the 6-digit code from your authenticator app:
1299
+ </label>
1300
+ <div class="flex gap-4">
1301
+ <input
1302
+ type="text"
1303
+ x-model="totpVerifyCode"
1304
+ inputmode="numeric"
1305
+ pattern="[0-9]*"
1306
+ maxlength="6"
1307
+ placeholder="000000"
1308
+ class="flex-grow bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none transition-all font-medium tracking-widest text-center text-lg"
1309
+ >
1310
+ <button
1311
+ @click="verifyTotp()"
1312
+ :disabled="totpVerifyCode.length !== 6 || totpVerifying"
1313
+ class="bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-6 rounded-lg transition-all flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
1314
+ >
1315
+ <template x-if="!totpVerifying">
1316
+ <span class="flex items-center gap-2">
1317
+ <i data-lucide="check" class="w-4 h-4"></i>
1318
+ Verify & Enable
1319
+ </span>
1320
+ </template>
1321
+ <template x-if="totpVerifying">
1322
+ <span class="flex items-center gap-2">
1323
+ <i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i>
1324
+ Verifying...
1325
+ </span>
1326
+ </template>
1327
+ </button>
1328
+ </div>
1329
+ <div x-show="totpError" x-cloak class="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-800 flex items-center gap-2">
1330
+ <i data-lucide="alert-circle" class="w-4 h-4"></i>
1331
+ <span x-text="totpError"></span>
1332
+ </div>
1333
+ <button
1334
+ @click="cancelTotpSetup()"
1335
+ class="text-sm text-gray-500 hover:text-gray-700 underline"
1336
+ >
1337
+ Cancel
1338
+ </button>
1339
+ </div>
1340
+ </div>
1341
+ </div>
1342
+
1343
+ <!-- Disable 2FA -->
1344
+ <div x-show="totpEnabled && !showTotpSetup" x-cloak class="border-t border-gray-100 pt-6 mt-6">
1345
+ <div x-show="!showDisableTotp">
1346
+ <button
1347
+ @click="showDisableTotp = true"
1348
+ class="text-sm text-red-600 hover:text-red-700 font-medium flex items-center gap-2"
1349
+ >
1350
+ <i data-lucide="shield-off" class="w-4 h-4"></i>
1351
+ Disable two-factor authentication
1352
+ </button>
1353
+ </div>
1354
+ <div x-show="showDisableTotp" class="space-y-4">
1355
+ <p class="text-sm text-gray-600">
1356
+ Enter your current authenticator code to disable 2FA:
1357
+ </p>
1358
+ <div class="flex gap-4">
1359
+ <input
1360
+ type="text"
1361
+ x-model="totpDisableCode"
1362
+ inputmode="numeric"
1363
+ pattern="[0-9]*"
1364
+ maxlength="6"
1365
+ placeholder="000000"
1366
+ class="flex-grow bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none transition-all font-medium tracking-widest text-center text-lg"
1367
+ >
1368
+ <button
1369
+ @click="disableTotp()"
1370
+ :disabled="totpDisableCode.length !== 6 || totpDisabling"
1371
+ class="bg-red-600 hover:bg-red-700 text-white font-bold py-3 px-6 rounded-lg transition-all flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
1372
+ >
1373
+ <template x-if="!totpDisabling">
1374
+ <span>Disable 2FA</span>
1375
+ </template>
1376
+ <template x-if="totpDisabling">
1377
+ <span class="flex items-center gap-2">
1378
+ <i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i>
1379
+ Disabling...
1380
+ </span>
1381
+ </template>
1382
+ </button>
1383
+ </div>
1384
+ <div x-show="totpError" x-cloak class="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-800 flex items-center gap-2">
1385
+ <i data-lucide="alert-circle" class="w-4 h-4"></i>
1386
+ <span x-text="totpError"></span>
1387
+ </div>
1388
+ <button
1389
+ @click="showDisableTotp = false; totpDisableCode = ''; totpError = ''"
1390
+ class="text-sm text-gray-500 hover:text-gray-700 underline"
1391
+ >
1392
+ Cancel
1393
+ </button>
1394
+ </div>
1395
+ </div>
1396
+ </div>
1397
+ </div>
1088
1398
  </div>
1089
1399
  </div>
1090
1400
  <script>
@@ -1126,6 +1436,19 @@ export const r2Backup = initialConfig => app => {
1126
1436
  config: ${JSON.stringify(config)},
1127
1437
  cronStatus: ${JSON.stringify(jobStatus)},
1128
1438
  configForm: { ...${JSON.stringify(config)} },
1439
+
1440
+ // TOTP State
1441
+ totpEnabled: ${!!config.auth?.totpSecret},
1442
+ showTotpSetup: false,
1443
+ showDisableTotp: false,
1444
+ totpLoading: false,
1445
+ totpVerifying: false,
1446
+ totpDisabling: false,
1447
+ totpSecret: '',
1448
+ totpQrCode: '',
1449
+ totpVerifyCode: '',
1450
+ totpDisableCode: '',
1451
+ totpError: '',
1129
1452
 
1130
1453
  init() {
1131
1454
  // Initial load if needed
@@ -1133,6 +1456,103 @@ export const r2Backup = initialConfig => app => {
1133
1456
  lucide.createIcons()
1134
1457
  })
1135
1458
  },
1459
+
1460
+ // TOTP Methods
1461
+ async generateTotp() {
1462
+ this.showTotpSetup = true;
1463
+ this.totpLoading = true;
1464
+ this.totpError = '';
1465
+ this.$nextTick(() => lucide.createIcons());
1466
+
1467
+ try {
1468
+ const response = await fetch('/backup/api/totp/generate', { method: 'POST' });
1469
+ const data = await response.json();
1470
+
1471
+ if (data.status === 'success') {
1472
+ this.totpSecret = data.secret;
1473
+ this.totpQrCode = data.qrCode;
1474
+ } else {
1475
+ this.totpError = data.message || 'Failed to generate TOTP';
1476
+ }
1477
+ } catch (err) {
1478
+ this.totpError = 'Connection failed. Please try again.';
1479
+ } finally {
1480
+ this.totpLoading = false;
1481
+ this.$nextTick(() => lucide.createIcons());
1482
+ }
1483
+ },
1484
+
1485
+ async verifyTotp() {
1486
+ this.totpVerifying = true;
1487
+ this.totpError = '';
1488
+
1489
+ try {
1490
+ const response = await fetch('/backup/api/totp/verify', {
1491
+ method: 'POST',
1492
+ headers: { 'Content-Type': 'application/json' },
1493
+ body: JSON.stringify({
1494
+ secret: this.totpSecret,
1495
+ code: this.totpVerifyCode
1496
+ })
1497
+ });
1498
+
1499
+ const data = await response.json();
1500
+
1501
+ if (data.status === 'success') {
1502
+ this.totpEnabled = true;
1503
+ this.showTotpSetup = false;
1504
+ this.totpSecret = '';
1505
+ this.totpQrCode = '';
1506
+ this.totpVerifyCode = '';
1507
+ this.addLog('Two-factor authentication enabled', 'success');
1508
+ } else {
1509
+ this.totpError = data.message || 'Verification failed';
1510
+ }
1511
+ } catch (err) {
1512
+ this.totpError = 'Connection failed. Please try again.';
1513
+ } finally {
1514
+ this.totpVerifying = false;
1515
+ this.$nextTick(() => lucide.createIcons());
1516
+ }
1517
+ },
1518
+
1519
+ cancelTotpSetup() {
1520
+ this.showTotpSetup = false;
1521
+ this.totpSecret = '';
1522
+ this.totpQrCode = '';
1523
+ this.totpVerifyCode = '';
1524
+ this.totpError = '';
1525
+ this.$nextTick(() => lucide.createIcons());
1526
+ },
1527
+
1528
+ async disableTotp() {
1529
+ this.totpDisabling = true;
1530
+ this.totpError = '';
1531
+
1532
+ try {
1533
+ const response = await fetch('/backup/api/totp/disable', {
1534
+ method: 'POST',
1535
+ headers: { 'Content-Type': 'application/json' },
1536
+ body: JSON.stringify({ code: this.totpDisableCode })
1537
+ });
1538
+
1539
+ const data = await response.json();
1540
+
1541
+ if (data.status === 'success') {
1542
+ this.totpEnabled = false;
1543
+ this.showDisableTotp = false;
1544
+ this.totpDisableCode = '';
1545
+ this.addLog('Two-factor authentication disabled', 'info');
1546
+ } else {
1547
+ this.totpError = data.message || 'Failed to disable 2FA';
1548
+ }
1549
+ } catch (err) {
1550
+ this.totpError = 'Connection failed. Please try again.';
1551
+ } finally {
1552
+ this.totpDisabling = false;
1553
+ this.$nextTick(() => lucide.createIcons());
1554
+ }
1555
+ },
1136
1556
 
1137
1557
  addLog(message, type = 'info') {
1138
1558
  this.logs.unshift({