@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 +1 -1
- package/src/index.js +117 -3
- package/src/views/OnboardingPage.js +22 -0
- package/src/views/components/OnboardingCard.js +278 -0
- package/src/views/scripts/onboardingApp.js +149 -0
package/package.json
CHANGED
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 (
|
|
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
|
+
`
|