@riligar/elysia-backup 1.4.0 → 1.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@riligar/elysia-backup",
3
- "version": "1.4.0",
3
+ "version": "1.6.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",
package/src/index.js CHANGED
@@ -14,6 +14,7 @@ import { html } from '@elysiajs/html'
14
14
  // Import page components
15
15
  import { LoginPage } from './views/LoginPage.js'
16
16
  import { DashboardPage } from './views/DashboardPage.js'
17
+ import { OnboardingPage } from './views/OnboardingPage.js'
17
18
 
18
19
  // Session Management
19
20
  const sessions = new Map()
@@ -93,6 +94,19 @@ export const r2Backup = initialConfig => app => {
93
94
  let config = { ...initialConfig, ...savedConfig }
94
95
  let backupJob = null
95
96
 
97
+ // Helper to check if config.json exists and has required fields
98
+ const hasValidConfig = () => {
99
+ if (!existsSync(configPath)) return false
100
+ try {
101
+ const content = readFileSync(configPath, 'utf-8')
102
+ const parsed = JSON.parse(content)
103
+ // Check minimum required fields for system to work
104
+ return !!(parsed.bucket && parsed.endpoint && parsed.accessKeyId && parsed.secretAccessKey && parsed.auth?.username && parsed.auth?.password)
105
+ } catch {
106
+ return false
107
+ }
108
+ }
109
+
96
110
  const getS3Client = () => {
97
111
  console.log('S3 Config:', {
98
112
  bucket: config.bucket,
@@ -275,13 +289,27 @@ export const r2Backup = initialConfig => app => {
275
289
  return app.use(html()).group('/backup', app => {
276
290
  // Authentication Middleware
277
291
  const authMiddleware = context => {
292
+ // Skip auth entirely if no valid config (needs onboarding)
293
+ if (!hasValidConfig()) {
294
+ return
295
+ }
296
+
278
297
  if (!config.auth || !config.auth.username || !config.auth.password) {
279
298
  return
280
299
  }
281
300
 
282
301
  const path = context.path
283
302
 
284
- if (path === '/backup/login' || path === '/backup/auth/login' || path === '/backup/auth/logout') {
303
+ // Skip auth for login, logout, onboarding, and static assets
304
+ if (
305
+ path === '/backup/login' ||
306
+ path === '/backup/auth/login' ||
307
+ path === '/backup/auth/logout' ||
308
+ path === '/backup/onboarding' ||
309
+ path === '/backup/api/onboarding' ||
310
+ path === '/backup/favicon.ico' ||
311
+ path === '/backup/logo.png'
312
+ ) {
285
313
  return
286
314
  }
287
315
 
@@ -601,8 +629,113 @@ export const r2Backup = initialConfig => app => {
601
629
  }
602
630
  )
603
631
 
632
+ // Static Assets: Favicon
633
+ .get('/favicon.ico', () => {
634
+ const faviconPath = new URL('./assets/favicon.ico', import.meta.url).pathname
635
+ const content = readFileSync(faviconPath)
636
+ return new Response(content, {
637
+ headers: { 'Content-Type': 'image/x-icon', 'Cache-Control': 'public, max-age=86400' },
638
+ })
639
+ })
640
+
641
+ // Static Assets: Logo
642
+ .get('/logo.png', () => {
643
+ const logoPath = new URL('./assets/logo.png', import.meta.url).pathname
644
+ const content = readFileSync(logoPath)
645
+ return new Response(content, {
646
+ headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' },
647
+ })
648
+ })
649
+
650
+ // ONBOARDING: Setup Page
651
+ .get('/onboarding', ({ set }) => {
652
+ // If already configured, redirect to dashboard
653
+ if (hasValidConfig()) {
654
+ set.status = 302
655
+ set.headers['Location'] = '/backup'
656
+ return
657
+ }
658
+ return OnboardingPage({ sourceDir: config.sourceDir })
659
+ })
660
+
661
+ // ONBOARDING: Save Initial Config
662
+ .post(
663
+ '/api/onboarding',
664
+ async ({ body, set }) => {
665
+ // Don't allow if already configured
666
+ if (hasValidConfig()) {
667
+ set.status = 403
668
+ return { status: 'error', message: 'System is already configured' }
669
+ }
670
+
671
+ const { endpoint, bucket, prefix, accessKeyId, secretAccessKey, extensions, cronSchedule, cronEnabled, username, password } = body
672
+
673
+ // Parse extensions
674
+ let parsedExtensions = []
675
+ if (extensions) {
676
+ parsedExtensions = extensions
677
+ .split(',')
678
+ .map(e => e.trim())
679
+ .filter(Boolean)
680
+ }
681
+
682
+ // Build initial config
683
+ const initialConfigData = {
684
+ endpoint,
685
+ bucket,
686
+ prefix: prefix || '',
687
+ accessKeyId,
688
+ secretAccessKey,
689
+ extensions: parsedExtensions,
690
+ cronSchedule: cronSchedule || '0 0 * * *',
691
+ cronEnabled: cronEnabled !== false,
692
+ auth: {
693
+ username,
694
+ password,
695
+ },
696
+ }
697
+
698
+ try {
699
+ await writeFile(configPath, JSON.stringify(initialConfigData, null, 2))
700
+
701
+ // Update runtime config
702
+ config = { ...config, ...initialConfigData }
703
+
704
+ // Setup cron if enabled
705
+ setupCron()
706
+
707
+ return { status: 'success', message: 'Configuration saved successfully' }
708
+ } catch (e) {
709
+ console.error('Failed to save onboarding config:', e)
710
+ set.status = 500
711
+ return { status: 'error', message: 'Failed to save configuration' }
712
+ }
713
+ },
714
+ {
715
+ body: t.Object({
716
+ endpoint: t.String(),
717
+ bucket: t.String(),
718
+ prefix: t.Optional(t.String()),
719
+ accessKeyId: t.String(),
720
+ secretAccessKey: t.String(),
721
+ extensions: t.Optional(t.String()),
722
+ cronSchedule: t.Optional(t.String()),
723
+ cronEnabled: t.Optional(t.Boolean()),
724
+ username: t.String(),
725
+ password: t.String(),
726
+ }),
727
+ }
728
+ )
729
+
604
730
  // UI: Dashboard
605
- .get('/', () => {
731
+ .get('/', ({ set }) => {
732
+ // Redirect to onboarding if no valid config
733
+ if (!hasValidConfig()) {
734
+ set.status = 302
735
+ set.headers['Location'] = '/backup/onboarding'
736
+ return
737
+ }
738
+
606
739
  const jobStatus = getJobStatus()
607
740
  const hasAuth = !!(config.auth && config.auth.username && config.auth.password)
608
741
  return DashboardPage({ config, jobStatus, hasAuth })
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Onboarding page component
3
+ * First-run setup wizard for configuring the backup system
4
+ * @returns {string} HTML string
5
+ */
6
+ import { Head } from './components/Head.js'
7
+ import { OnboardingCard } from './components/OnboardingCard.js'
8
+ import { onboardingAppScript } from './scripts/onboardingApp.js'
9
+
10
+ export const OnboardingPage = ({ sourceDir }) => `
11
+ <!DOCTYPE html>
12
+ <html lang="en">
13
+ <head>
14
+ ${Head({ title: 'Setup - Backup Manager' })}
15
+ </head>
16
+ <body class="bg-gray-50 min-h-screen flex items-center justify-center p-6 antialiased">
17
+ ${OnboardingCard({ sourceDir })}
18
+
19
+ ${onboardingAppScript({ sourceDir })}
20
+ </body>
21
+ </html>
22
+ `
@@ -14,7 +14,7 @@ export const ActionArea = () => `
14
14
  <button
15
15
  @click="runBackup()"
16
16
  :disabled="loading"
17
- class="group relative inline-flex items-center justify-center px-8 py-3.5 text-base font-semibold text-white transition-all duration-200 bg-gray-900 rounded-xl hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 disabled:opacity-70 disabled:cursor-not-allowed">
17
+ class="group relative inline-flex items-center justify-center px-8 py-3.5 text-base font-semibold text-white transition-all duration-200 bg-primary-500 rounded-xl hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-70 disabled:cursor-not-allowed">
18
18
  <span x-show="!loading" class="flex items-center gap-2">
19
19
  <i data-lucide="play-circle" class="w-5 h-5"></i>
20
20
  Start Backup Process
@@ -57,10 +57,10 @@ export const FilesTab = () => `
57
57
  x-data="holdButton(() => restoreFile(file.key))"
58
58
  @mousedown="start()" @touchstart.prevent="start()"
59
59
  @mouseup="stop()" @mouseleave="stop()" @touchend="stop()"
60
- class="relative overflow-hidden px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider text-blue-600 bg-blue-50 hover:bg-blue-100 transition-colors select-none"
60
+ class="relative overflow-hidden px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider text-primary-600 bg-primary-50 hover:bg-primary-100 transition-colors select-none"
61
61
  title="Hold 3s to Restore"
62
62
  >
63
- <div class="absolute inset-0 bg-blue-200/50 origin-left transition-all duration-0 ease-linear" :style="'width: ' + progress + '%'"></div>
63
+ <div class="absolute inset-0 bg-primary-200/50 origin-left transition-all duration-0 ease-linear" :style="'width: ' + progress + '%'"></div>
64
64
  <span class="relative z-10 flex items-center gap-2">
65
65
  <i data-lucide="rotate-ccw" class="w-3 h-3"></i>
66
66
  <span x-text="progress > 0 ? 'Hold...' : 'Restore'"></span>
@@ -8,6 +8,7 @@ export const Head = ({ title = 'Backup Manager' }) => `
8
8
  <meta charset="UTF-8">
9
9
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
10
10
  <title>${title}</title>
11
+ <link rel="icon" type="image/x-icon" href="/backup/favicon.ico">
11
12
  <script src="https://cdn.tailwindcss.com"></script>
12
13
  <script>
13
14
  tailwind.config = {
@@ -17,6 +18,18 @@ export const Head = ({ title = 'Backup Manager' }) => `
17
18
  sans: ['Montserrat', 'sans-serif'],
18
19
  },
19
20
  colors: {
21
+ primary: {
22
+ 50: '#E8F4FD',
23
+ 100: '#C5E3FA',
24
+ 200: '#9DD0F6',
25
+ 300: '#6DB9F1',
26
+ 400: '#47A7ED',
27
+ 500: '#209CEE',
28
+ 600: '#1A8AD8',
29
+ 700: '#1574B8',
30
+ 800: '#115E95',
31
+ 900: '#0C4A77',
32
+ },
20
33
  gray: {
21
34
  50: '#F9FAFB',
22
35
  100: '#F3F4F6',
@@ -5,33 +5,35 @@
5
5
  */
6
6
  export const Header = ({ hasAuth }) => `
7
7
  <div class="flex flex-col md:flex-row md:items-end justify-between mb-16 gap-6">
8
- <div>
9
- <h6 class="text-xs font-bold tracking-widest text-gray-500 uppercase mb-2 flex items-center gap-2">
10
- <i data-lucide="shield-check" class="w-4 h-4"></i>
11
- System Administration
12
- </h6>
13
- <h1 class="text-4xl font-bold text-gray-900 tracking-tight flex items-center gap-3">
14
- Backup Manager
15
- </h1>
8
+ <div class="flex items-center gap-4">
9
+ <img src="/backup/logo.png" alt="Backup Manager" class="w-12 h-12 rounded-lg">
10
+ <div>
11
+ <h6 class="text-xs font-bold tracking-widest text-gray-500 uppercase mb-1 flex items-center gap-2">
12
+ System Administration
13
+ </h6>
14
+ <h1 class="text-3xl font-bold text-gray-900 tracking-tight">
15
+ Backup Manager
16
+ </h1>
17
+ </div>
16
18
  </div>
17
19
 
18
20
  <div class="flex items-center gap-4">
19
21
  <!-- Tabs -->
20
- <div class="flex p-1 bg-gray-200/50 rounded-xl">
22
+ <div class="flex p-1 bg-primary-100/50 rounded-xl">
21
23
  <button @click="activeTab = 'dashboard'; $nextTick(() => lucide.createIcons())"
22
- :class="activeTab === 'dashboard' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
24
+ :class="activeTab === 'dashboard' ? 'bg-white text-primary-700 shadow-sm' : 'text-gray-500 hover:text-primary-600'"
23
25
  class="px-6 py-2.5 rounded-lg text-sm font-semibold transition-all duration-200 flex items-center gap-2">
24
26
  <i data-lucide="layout-dashboard" class="w-4 h-4"></i>
25
27
  Overview
26
28
  </button>
27
29
  <button @click="activeTab = 'files'; fetchFiles()"
28
- :class="activeTab === 'files' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
30
+ :class="activeTab === 'files' ? 'bg-white text-primary-700 shadow-sm' : 'text-gray-500 hover:text-primary-600'"
29
31
  class="px-6 py-2.5 rounded-lg text-sm font-semibold transition-all duration-200 flex items-center gap-2">
30
32
  <i data-lucide="folder-open" class="w-4 h-4"></i>
31
33
  Files & Restore
32
34
  </button>
33
35
  <button @click="activeTab = 'settings'; $nextTick(() => lucide.createIcons())"
34
- :class="activeTab === 'settings' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
36
+ :class="activeTab === 'settings' ? 'bg-white text-primary-700 shadow-sm' : 'text-gray-500 hover:text-primary-600'"
35
37
  class="px-6 py-2.5 rounded-lg text-sm font-semibold transition-all duration-200 flex items-center gap-2">
36
38
  <i data-lucide="settings" class="w-4 h-4"></i>
37
39
  Settings
@@ -9,9 +9,7 @@ export const LoginCard = ({ totpEnabled }) => `
9
9
  <div class="bg-white rounded-2xl border border-gray-100 shadow-[0_8px_30px_rgba(0,0,0,0.08)] overflow-hidden">
10
10
  <!-- Header -->
11
11
  <div class="p-10 text-center border-b border-gray-100">
12
- <div class="w-16 h-16 bg-gray-900 rounded-full flex items-center justify-center mx-auto mb-4">
13
- <i data-lucide="shield-check" class="w-8 h-8 text-white"></i>
14
- </div>
12
+ <img src="/backup/logo.png" alt="Backup Manager" class="w-20 h-20 mx-auto mb-4 rounded-xl">
15
13
  <h1 class="text-2xl font-bold text-gray-900 mb-2">Backup Manager</h1>
16
14
  <p class="text-sm text-gray-500">Access Control Panel</p>
17
15
  </div>
@@ -38,7 +36,7 @@ export const LoginCard = ({ totpEnabled }) => `
38
36
  type="text"
39
37
  x-model="username"
40
38
  required
41
- 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"
39
+ 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-primary-500 focus:border-transparent outline-none transition-all font-medium"
42
40
  placeholder="Enter your username"
43
41
  autofocus
44
42
  >
@@ -56,7 +54,7 @@ export const LoginCard = ({ totpEnabled }) => `
56
54
  type="password"
57
55
  x-model="password"
58
56
  required
59
- 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"
57
+ 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-primary-500 focus:border-transparent outline-none transition-all font-medium"
60
58
  placeholder="Enter your password"
61
59
  >
62
60
  </div>
@@ -76,7 +74,7 @@ export const LoginCard = ({ totpEnabled }) => `
76
74
  pattern="[0-9]*"
77
75
  maxlength="6"
78
76
  :required="totpEnabled"
79
- 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"
77
+ 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-primary-500 focus:border-transparent outline-none transition-all font-medium tracking-widest text-center text-lg"
80
78
  placeholder="000000"
81
79
  >
82
80
  </div>
@@ -90,7 +88,7 @@ export const LoginCard = ({ totpEnabled }) => `
90
88
  <button
91
89
  type="submit"
92
90
  :disabled="loading"
93
- class="w-full bg-gray-900 hover:bg-gray-800 text-white font-bold py-3.5 px-6 rounded-xl transition-all shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none"
91
+ class="w-full bg-primary-500 hover:bg-primary-600 text-white font-bold py-3.5 px-6 rounded-xl transition-all shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none"
94
92
  >
95
93
  <span x-show="!loading" class="flex items-center gap-2">
96
94
  <span>Sign In</span>
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Onboarding card component - Multi-step setup wizard
3
+ * @param {{ sourceDir: string }} props
4
+ * @returns {string} HTML string
5
+ */
6
+ export const OnboardingCard = ({ sourceDir }) => `
7
+ <div class="w-full max-w-2xl" x-data="onboardingApp()">
8
+ <!-- Onboarding Card -->
9
+ <div class="bg-white rounded-2xl border border-gray-100 shadow-[0_8px_30px_rgba(0,0,0,0.08)] overflow-hidden">
10
+ <!-- Header -->
11
+ <div class="p-8 text-center border-b border-gray-100 bg-gradient-to-br from-primary-50 to-white">
12
+ <img src="/backup/logo.png" alt="Backup Manager" class="w-16 h-16 mx-auto mb-4 rounded-xl">
13
+ <h1 class="text-2xl font-bold text-gray-900 mb-2">Welcome to Backup Manager</h1>
14
+ <p class="text-sm text-gray-500">Let's configure your backup system in a few steps</p>
15
+ </div>
16
+
17
+ <!-- Progress Steps -->
18
+ <div class="px-8 py-4 bg-gray-50 border-b border-gray-100">
19
+ <div class="flex items-center justify-center gap-2">
20
+ <template x-for="(stepName, index) in ['Storage', 'Backup', 'Security']" :key="index">
21
+ <div class="flex items-center">
22
+ <div
23
+ :class="step > index + 1 ? 'bg-primary-500 text-white' : (step === index + 1 ? 'bg-primary-500 text-white ring-4 ring-primary-100' : 'bg-gray-200 text-gray-500')"
24
+ class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-all"
25
+ >
26
+ <span x-show="step <= index + 1" x-text="index + 1"></span>
27
+ <i x-show="step > index + 1" data-lucide="check" class="w-4 h-4"></i>
28
+ </div>
29
+ <span
30
+ :class="step >= index + 1 ? 'text-gray-900 font-semibold' : 'text-gray-400'"
31
+ class="ml-2 text-sm hidden sm:inline"
32
+ x-text="stepName"
33
+ ></span>
34
+ <div x-show="index < 2" class="w-8 h-0.5 mx-3 bg-gray-200 hidden sm:block">
35
+ <div
36
+ :class="step > index + 1 ? 'w-full' : 'w-0'"
37
+ class="h-full bg-primary-500 transition-all duration-300"
38
+ ></div>
39
+ </div>
40
+ </div>
41
+ </template>
42
+ </div>
43
+ </div>
44
+
45
+ <!-- Form -->
46
+ <div class="p-8">
47
+ <!-- Error Message -->
48
+ <div x-show="error" x-cloak class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
49
+ <div class="flex items-center gap-3">
50
+ <i data-lucide="alert-circle" class="w-5 h-5 text-red-600"></i>
51
+ <span class="text-sm text-red-800 font-medium" x-text="error"></span>
52
+ </div>
53
+ </div>
54
+
55
+ <!-- Step 1: Storage Configuration -->
56
+ <div x-show="step === 1" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 translate-x-4" x-transition:enter-end="opacity-100 translate-x-0">
57
+ <h2 class="text-lg font-bold text-gray-900 mb-1 flex items-center gap-2">
58
+ <i data-lucide="cloud" class="w-5 h-5 text-primary-500"></i>
59
+ Storage Configuration
60
+ </h2>
61
+ <p class="text-sm text-gray-500 mb-6">Configure your R2/S3 compatible storage</p>
62
+
63
+ <div class="space-y-5">
64
+ <div>
65
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Endpoint URL *</label>
66
+ <input
67
+ type="url"
68
+ x-model="form.endpoint"
69
+ placeholder="https://your-account.r2.cloudflarestorage.com"
70
+ class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium"
71
+ >
72
+ </div>
73
+ <div class="grid grid-cols-2 gap-4">
74
+ <div>
75
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Bucket Name *</label>
76
+ <input
77
+ type="text"
78
+ x-model="form.bucket"
79
+ placeholder="my-backups"
80
+ class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium"
81
+ >
82
+ </div>
83
+ <div>
84
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Prefix (Folder)</label>
85
+ <input
86
+ type="text"
87
+ x-model="form.prefix"
88
+ placeholder="backups/"
89
+ class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium"
90
+ >
91
+ </div>
92
+ </div>
93
+ <div class="grid grid-cols-2 gap-4">
94
+ <div>
95
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Access Key ID *</label>
96
+ <input
97
+ type="text"
98
+ x-model="form.accessKeyId"
99
+ placeholder="Your access key"
100
+ class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium"
101
+ >
102
+ </div>
103
+ <div>
104
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Secret Access Key *</label>
105
+ <input
106
+ type="password"
107
+ x-model="form.secretAccessKey"
108
+ placeholder="Your secret key"
109
+ class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium"
110
+ >
111
+ </div>
112
+ </div>
113
+ </div>
114
+ </div>
115
+
116
+ <!-- Step 2: Backup Configuration -->
117
+ <div x-show="step === 2" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 translate-x-4" x-transition:enter-end="opacity-100 translate-x-0">
118
+ <h2 class="text-lg font-bold text-gray-900 mb-1 flex items-center gap-2">
119
+ <i data-lucide="hard-drive" class="w-5 h-5 text-primary-500"></i>
120
+ Backup Configuration
121
+ </h2>
122
+ <p class="text-sm text-gray-500 mb-6">Configure backup source and schedule</p>
123
+
124
+ <div class="space-y-5">
125
+ <div>
126
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Source Directory</label>
127
+ <input
128
+ type="text"
129
+ value="${sourceDir}"
130
+ disabled
131
+ class="w-full bg-gray-100 border border-gray-200 rounded-lg px-4 py-3 text-gray-500 cursor-not-allowed font-medium"
132
+ >
133
+ <p class="text-xs text-gray-400 mt-1">Defined in server configuration</p>
134
+ </div>
135
+ <div>
136
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Allowed Extensions</label>
137
+ <input
138
+ type="text"
139
+ x-model="form.extensions"
140
+ placeholder=".db, .sqlite, .json (leave empty for all files)"
141
+ class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium"
142
+ >
143
+ <p class="text-xs text-gray-400 mt-1">Comma-separated list of file extensions to backup</p>
144
+ </div>
145
+ <div>
146
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Cron Schedule</label>
147
+ <input
148
+ type="text"
149
+ x-model="form.cronSchedule"
150
+ placeholder="0 0 * * * (daily at midnight)"
151
+ class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium"
152
+ >
153
+ <p class="text-xs text-gray-400 mt-1">
154
+ Format: Minute Hour Day Month DayOfWeek.
155
+ <a href="https://crontab.guru/" target="_blank" class="text-primary-500 hover:underline">Help</a>
156
+ </p>
157
+ </div>
158
+ <div class="flex items-center gap-3 p-4 bg-gray-50 rounded-lg">
159
+ <input
160
+ type="checkbox"
161
+ x-model="form.cronEnabled"
162
+ id="cronEnabled"
163
+ class="w-5 h-5 text-primary-500 rounded border-gray-300 focus:ring-primary-500"
164
+ >
165
+ <label for="cronEnabled" class="text-sm font-medium text-gray-700">Enable scheduled backups</label>
166
+ </div>
167
+ </div>
168
+ </div>
169
+
170
+ <!-- Step 3: Security Configuration -->
171
+ <div x-show="step === 3" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 translate-x-4" x-transition:enter-end="opacity-100 translate-x-0">
172
+ <h2 class="text-lg font-bold text-gray-900 mb-1 flex items-center gap-2">
173
+ <i data-lucide="shield" class="w-5 h-5 text-primary-500"></i>
174
+ Security Configuration
175
+ </h2>
176
+ <p class="text-sm text-gray-500 mb-6">Set up your admin credentials</p>
177
+
178
+ <div class="space-y-5">
179
+ <div>
180
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Admin Username *</label>
181
+ <div class="relative">
182
+ <div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
183
+ <i data-lucide="user" class="w-5 h-5 text-gray-400"></i>
184
+ </div>
185
+ <input
186
+ type="text"
187
+ x-model="form.username"
188
+ placeholder="admin"
189
+ 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-primary-500 focus:border-transparent outline-none transition-all font-medium"
190
+ >
191
+ </div>
192
+ </div>
193
+ <div>
194
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Password *</label>
195
+ <div class="relative">
196
+ <div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
197
+ <i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
198
+ </div>
199
+ <input
200
+ type="password"
201
+ x-model="form.password"
202
+ placeholder="Enter a strong password"
203
+ 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-primary-500 focus:border-transparent outline-none transition-all font-medium"
204
+ >
205
+ </div>
206
+ </div>
207
+ <div>
208
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Confirm Password *</label>
209
+ <div class="relative">
210
+ <div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
211
+ <i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
212
+ </div>
213
+ <input
214
+ type="password"
215
+ x-model="form.confirmPassword"
216
+ placeholder="Confirm your password"
217
+ 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-primary-500 focus:border-transparent outline-none transition-all font-medium"
218
+ :class="form.confirmPassword && form.password !== form.confirmPassword ? 'border-red-300 focus:ring-red-500' : ''"
219
+ >
220
+ </div>
221
+ <p x-show="form.confirmPassword && form.password !== form.confirmPassword" class="text-xs text-red-500 mt-1">
222
+ Passwords do not match
223
+ </p>
224
+ </div>
225
+
226
+ <div class="p-4 bg-primary-50 rounded-lg border border-primary-100">
227
+ <div class="flex items-start gap-3">
228
+ <i data-lucide="info" class="w-5 h-5 text-primary-600 mt-0.5"></i>
229
+ <div class="text-sm text-primary-800">
230
+ <p class="font-medium mb-1">Two-Factor Authentication</p>
231
+ <p class="text-primary-700">You can enable 2FA later in the Security settings after completing the setup.</p>
232
+ </div>
233
+ </div>
234
+ </div>
235
+ </div>
236
+ </div>
237
+
238
+ <!-- Navigation Buttons -->
239
+ <div class="flex justify-between mt-8 pt-6 border-t border-gray-100">
240
+ <button
241
+ x-show="step > 1"
242
+ @click="prevStep()"
243
+ class="px-6 py-3 text-gray-600 hover:text-gray-900 font-semibold transition-colors flex items-center gap-2"
244
+ >
245
+ <i data-lucide="arrow-left" class="w-4 h-4"></i>
246
+ Back
247
+ </button>
248
+ <div x-show="step === 1"></div>
249
+
250
+ <button
251
+ x-show="step < 3"
252
+ @click="nextStep()"
253
+ class="px-8 py-3 bg-primary-500 hover:bg-primary-600 text-white font-bold rounded-xl transition-all shadow-lg hover:shadow-xl flex items-center gap-2"
254
+ >
255
+ Continue
256
+ <i data-lucide="arrow-right" class="w-4 h-4"></i>
257
+ </button>
258
+
259
+ <button
260
+ x-show="step === 3"
261
+ @click="submitSetup()"
262
+ :disabled="loading || !canSubmit()"
263
+ class="px-8 py-3 bg-primary-500 hover:bg-primary-600 text-white font-bold rounded-xl transition-all shadow-lg hover:shadow-xl flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
264
+ >
265
+ <span x-show="!loading" class="flex items-center gap-2">
266
+ <i data-lucide="check-circle" class="w-5 h-5"></i>
267
+ Complete Setup
268
+ </span>
269
+ <span x-show="loading" class="flex items-center gap-2">
270
+ <i data-lucide="loader-2" class="w-5 h-5 animate-spin"></i>
271
+ Saving...
272
+ </span>
273
+ </button>
274
+ </div>
275
+ </div>
276
+ </div>
277
+ </div>
278
+ `
@@ -24,7 +24,7 @@ export const SecuritySection = () => `
24
24
  <div x-show="!totpEnabled && !showTotpSetup">
25
25
  <button
26
26
  @click="generateTotp()"
27
- 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"
27
+ class="bg-primary-500 hover:bg-primary-600 text-white font-bold py-2.5 px-5 rounded-lg transition-all flex items-center gap-2"
28
28
  >
29
29
  <i data-lucide="plus" class="w-4 h-4"></i>
30
30
  Enable 2FA
@@ -73,7 +73,7 @@ export const SecuritySection = () => `
73
73
  pattern="[0-9]*"
74
74
  maxlength="6"
75
75
  placeholder="000000"
76
- 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"
76
+ class="flex-grow bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium tracking-widest text-center text-lg"
77
77
  >
78
78
  <button
79
79
  @click="verifyTotp()"
@@ -131,7 +131,7 @@ export const SecuritySection = () => `
131
131
  pattern="[0-9]*"
132
132
  maxlength="6"
133
133
  placeholder="000000"
134
- 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"
134
+ class="flex-grow bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium tracking-widest text-center text-lg"
135
135
  >
136
136
  <button
137
137
  @click="disableTotp()"
@@ -12,16 +12,16 @@ export const SettingsTab = () => `
12
12
  <div class="space-y-6">
13
13
  <div>
14
14
  <label class="block text-sm font-semibold text-gray-700 mb-2">Endpoint URL</label>
15
- <input type="text" x-model="configForm.endpoint" class="w-full 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">
15
+ <input type="text" x-model="configForm.endpoint" class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium">
16
16
  </div>
17
17
  <div class="grid grid-cols-2 gap-6">
18
18
  <div>
19
19
  <label class="block text-sm font-semibold text-gray-700 mb-2">Bucket Name</label>
20
- <input type="text" x-model="configForm.bucket" class="w-full 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">
20
+ <input type="text" x-model="configForm.bucket" class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium">
21
21
  </div>
22
22
  <div>
23
23
  <label class="block text-sm font-semibold text-gray-700 mb-2">Prefix (Folder)</label>
24
- <input type="text" x-model="configForm.prefix" class="w-full 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">
24
+ <input type="text" x-model="configForm.prefix" class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium">
25
25
  </div>
26
26
  </div>
27
27
  <div>
@@ -31,13 +31,13 @@ export const SettingsTab = () => `
31
31
  </div>
32
32
  <div>
33
33
  <label class="block text-sm font-semibold text-gray-700 mb-2">Allowed Extensions (comma separated)</label>
34
- <input type="text" x-model="configForm.extensions" placeholder=".db, .sqlite" class="w-full 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">
34
+ <input type="text" x-model="configForm.extensions" placeholder=".db, .sqlite" class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium">
35
35
  </div>
36
36
  <div>
37
37
  <label class="block text-sm font-semibold text-gray-700 mb-2">Cron Schedule</label>
38
38
  <div class="flex gap-4 items-center">
39
39
  <div class="relative flex-grow">
40
- <input type="text" x-model="configForm.cronSchedule" placeholder="0 0 * * *" class="w-full 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">
40
+ <input type="text" x-model="configForm.cronSchedule" placeholder="0 0 * * *" class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium">
41
41
  <div class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-500">
42
42
  <a href="https://crontab.guru/" target="_blank" class="underline hover:text-gray-800">Help</a>
43
43
  </div>
@@ -68,17 +68,17 @@ export const SettingsTab = () => `
68
68
  <div class="grid grid-cols-2 gap-6">
69
69
  <div>
70
70
  <label class="block text-sm font-semibold text-gray-700 mb-2">Access Key ID</label>
71
- <input type="text" x-model="configForm.accessKeyId" class="w-full 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">
71
+ <input type="text" x-model="configForm.accessKeyId" class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium">
72
72
  </div>
73
73
  <div>
74
74
  <label class="block text-sm font-semibold text-gray-700 mb-2">Secret Access Key</label>
75
- <input type="password" x-model="configForm.secretAccessKey" class="w-full 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">
75
+ <input type="password" x-model="configForm.secretAccessKey" class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium">
76
76
  </div>
77
77
  </div>
78
78
  </div>
79
79
 
80
80
  <div class="pt-4 flex justify-end">
81
- <button type="submit" class="bg-gray-900 hover:bg-gray-800 text-white font-bold py-3 px-8 rounded-xl transition-all shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 flex items-center gap-2">
81
+ <button type="submit" class="bg-primary-500 hover:bg-primary-600 text-white font-bold py-3 px-8 rounded-xl transition-all shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 flex items-center gap-2">
82
82
  <i data-lucide="save" class="w-4 h-4"></i>
83
83
  Save Changes
84
84
  </button>
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Onboarding Alpine.js application script
3
+ * Handles multi-step form navigation and submission
4
+ * @param {{ sourceDir: string }} props
5
+ * @returns {string} Script tag with onboarding logic
6
+ */
7
+ export const onboardingAppScript = ({ sourceDir }) => `
8
+ <script>
9
+ document.addEventListener('alpine:init', () => {
10
+ Alpine.data('onboardingApp', () => ({
11
+ step: 1,
12
+ loading: false,
13
+ error: '',
14
+ form: {
15
+ // Storage
16
+ endpoint: '',
17
+ bucket: '',
18
+ prefix: '',
19
+ accessKeyId: '',
20
+ secretAccessKey: '',
21
+ // Backup
22
+ extensions: '',
23
+ cronSchedule: '0 0 * * *',
24
+ cronEnabled: true,
25
+ // Security
26
+ username: '',
27
+ password: '',
28
+ confirmPassword: ''
29
+ },
30
+
31
+ init() {
32
+ this.$nextTick(() => lucide.createIcons());
33
+ },
34
+
35
+ validateStep1() {
36
+ if (!this.form.endpoint) {
37
+ this.error = 'Endpoint URL is required';
38
+ return false;
39
+ }
40
+ if (!this.form.bucket) {
41
+ this.error = 'Bucket name is required';
42
+ return false;
43
+ }
44
+ if (!this.form.accessKeyId) {
45
+ this.error = 'Access Key ID is required';
46
+ return false;
47
+ }
48
+ if (!this.form.secretAccessKey) {
49
+ this.error = 'Secret Access Key is required';
50
+ return false;
51
+ }
52
+ return true;
53
+ },
54
+
55
+ validateStep2() {
56
+ // Step 2 has no required fields
57
+ return true;
58
+ },
59
+
60
+ validateStep3() {
61
+ if (!this.form.username) {
62
+ this.error = 'Username is required';
63
+ return false;
64
+ }
65
+ if (!this.form.password) {
66
+ this.error = 'Password is required';
67
+ return false;
68
+ }
69
+ if (this.form.password.length < 8) {
70
+ this.error = 'Password must be at least 8 characters';
71
+ return false;
72
+ }
73
+ if (this.form.password !== this.form.confirmPassword) {
74
+ this.error = 'Passwords do not match';
75
+ return false;
76
+ }
77
+ return true;
78
+ },
79
+
80
+ nextStep() {
81
+ this.error = '';
82
+
83
+ if (this.step === 1 && !this.validateStep1()) return;
84
+ if (this.step === 2 && !this.validateStep2()) return;
85
+
86
+ this.step++;
87
+ this.$nextTick(() => lucide.createIcons());
88
+ },
89
+
90
+ prevStep() {
91
+ this.error = '';
92
+ this.step--;
93
+ this.$nextTick(() => lucide.createIcons());
94
+ },
95
+
96
+ canSubmit() {
97
+ return this.form.username &&
98
+ this.form.password &&
99
+ this.form.password === this.form.confirmPassword &&
100
+ this.form.password.length >= 8;
101
+ },
102
+
103
+ async submitSetup() {
104
+ this.error = '';
105
+
106
+ if (!this.validateStep3()) return;
107
+
108
+ this.loading = true;
109
+
110
+ try {
111
+ const response = await fetch('/backup/api/onboarding', {
112
+ method: 'POST',
113
+ headers: { 'Content-Type': 'application/json' },
114
+ body: JSON.stringify({
115
+ endpoint: this.form.endpoint,
116
+ bucket: this.form.bucket,
117
+ prefix: this.form.prefix,
118
+ accessKeyId: this.form.accessKeyId,
119
+ secretAccessKey: this.form.secretAccessKey,
120
+ extensions: this.form.extensions,
121
+ cronSchedule: this.form.cronSchedule,
122
+ cronEnabled: this.form.cronEnabled,
123
+ username: this.form.username,
124
+ password: this.form.password
125
+ })
126
+ });
127
+
128
+ const data = await response.json();
129
+
130
+ if (data.status === 'success') {
131
+ window.location.href = '/backup';
132
+ } else {
133
+ this.error = data.message || 'Setup failed. Please try again.';
134
+ }
135
+ } catch (e) {
136
+ this.error = 'Connection error. Please try again.';
137
+ } finally {
138
+ this.loading = false;
139
+ this.$nextTick(() => lucide.createIcons());
140
+ }
141
+ }
142
+ }));
143
+ });
144
+
145
+ document.addEventListener('DOMContentLoaded', () => {
146
+ lucide.createIcons();
147
+ });
148
+ </script>
149
+ `