@riligar/elysia-backup 1.5.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.5.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,14 +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
- // Skip auth for login, logout, and static assets
285
- if (path === '/backup/login' || path === '/backup/auth/login' || path === '/backup/auth/logout' || path === '/backup/favicon.ico' || path === '/backup/logo.png') {
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
+ ) {
286
313
  return
287
314
  }
288
315
 
@@ -620,8 +647,95 @@ export const r2Backup = initialConfig => app => {
620
647
  })
621
648
  })
622
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
+
623
730
  // UI: Dashboard
624
- .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
+
625
739
  const jobStatus = getJobStatus()
626
740
  const hasAuth = !!(config.auth && config.auth.username && config.auth.password)
627
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
+ `
@@ -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
+ `
@@ -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
+ `