@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 +12 -12
- package/package.json +4 -2
- package/src/elysia-backup.js +428 -8
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: './
|
|
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.
|
|
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",
|
package/src/elysia-backup.js
CHANGED
|
@@ -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: './
|
|
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 || './
|
|
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.
|
|
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({
|