@riligar/elysia-backup 1.3.0 → 1.4.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.
@@ -0,0 +1,169 @@
1
+ import { readdir, stat, readFile, writeFile, mkdir } from 'node:fs/promises'
2
+ import { join, relative, dirname } from 'node:path'
3
+ import { createS3Client } from './s3-client.js'
4
+
5
+ /**
6
+ * Create a backup service instance
7
+ * @param {Function} getConfig - Function to get current configuration
8
+ * @returns {Object} Backup service with all operations
9
+ */
10
+ export const createBackupService = getConfig => {
11
+ /**
12
+ * Get a configured S3 client
13
+ */
14
+ const getS3 = () => createS3Client(getConfig())
15
+
16
+ /**
17
+ * Upload a single file to S3
18
+ * @param {string} filePath - Local file path
19
+ * @param {string} rootDir - Root directory for relative path calculation
20
+ * @param {string} timestampPrefix - Timestamp to prepend to filename
21
+ */
22
+ const uploadFile = async (filePath, rootDir, timestampPrefix) => {
23
+ const config = getConfig()
24
+ const s3 = getS3()
25
+ const fileContent = await readFile(filePath)
26
+
27
+ const relativePath = relative(rootDir, filePath)
28
+ const dir = dirname(relativePath)
29
+ const filename = relativePath.split('/').pop()
30
+
31
+ // Format: YYYY-MM-DD_HH-mm-ss_filename.ext
32
+ const timestamp = timestampPrefix || new Date().toISOString()
33
+ const newFilename = `${timestamp}_${filename}`
34
+
35
+ // Reconstruct path with new filename
36
+ const finalPath = dir === '.' ? newFilename : join(dir, newFilename)
37
+ const key = config.prefix ? join(config.prefix, finalPath) : finalPath
38
+
39
+ console.log(`Uploading ${key}...`)
40
+ await s3.write(key, fileContent)
41
+ }
42
+
43
+ /**
44
+ * Process a directory recursively and upload all matching files
45
+ * @param {string} dir - Directory to process
46
+ * @param {string} timestampPrefix - Timestamp for all files in this batch
47
+ */
48
+ const processDirectory = async (dir, timestampPrefix) => {
49
+ const config = getConfig()
50
+ const files = await readdir(dir)
51
+
52
+ for (const file of files) {
53
+ const fullPath = join(dir, file)
54
+ const stats = await stat(fullPath)
55
+
56
+ if (stats.isDirectory()) {
57
+ await processDirectory(fullPath, timestampPrefix)
58
+ } else {
59
+ // Filter by extension
60
+ const allowedExtensions = config.extensions || []
61
+ const hasExtension = allowedExtensions.some(ext => file.endsWith(ext))
62
+
63
+ // Skip files that look like backups to prevent recursion/duplication
64
+ const timestampRegex = /^(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)_/
65
+ if (timestampRegex.test(file)) {
66
+ console.log(`Skipping backup-like file: ${file}`)
67
+ continue
68
+ }
69
+
70
+ if (allowedExtensions.length === 0 || hasExtension) {
71
+ await uploadFile(fullPath, config.sourceDir, timestampPrefix)
72
+ }
73
+ }
74
+ }
75
+ }
76
+
77
+ /**
78
+ * List all files in the remote bucket
79
+ * @returns {Array} List of file objects with Key, Size, LastModified
80
+ */
81
+ const listRemoteFiles = async () => {
82
+ const config = getConfig()
83
+ const s3 = getS3()
84
+
85
+ try {
86
+ const response = await s3.list({ prefix: config.prefix || '' })
87
+
88
+ // Handle array response
89
+ if (Array.isArray(response)) {
90
+ return response.map(f => ({
91
+ Key: f.key || f.name,
92
+ Size: f.size,
93
+ LastModified: f.lastModified,
94
+ }))
95
+ }
96
+
97
+ // Handle AWS-like response with contents
98
+ if (response.contents) {
99
+ return response.contents.map(f => ({
100
+ Key: f.key,
101
+ Size: f.size,
102
+ LastModified: f.lastModified,
103
+ }))
104
+ }
105
+
106
+ console.log('Unknown list response structure:', response)
107
+ return []
108
+ } catch (e) {
109
+ console.error('Error listing files with Bun.s3:', e)
110
+ return []
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Restore a file from backup to local filesystem
116
+ * @param {string} key - S3 key of the file to restore
117
+ * @returns {string} Local path where file was restored
118
+ */
119
+ const restoreFile = async key => {
120
+ const config = getConfig()
121
+ const s3 = getS3()
122
+ const file = s3.file(key)
123
+
124
+ if (!(await file.exists())) {
125
+ throw new Error(`File ${key} not found in bucket`)
126
+ }
127
+
128
+ const arrayBuffer = await file.arrayBuffer()
129
+ const byteArray = new Uint8Array(arrayBuffer)
130
+
131
+ // Remove prefix from key to get relative path
132
+ const relativePath = config.prefix ? key.replace(config.prefix, '') : key
133
+ const cleanRelative = relativePath.replace(/^[\/\\]/, '')
134
+
135
+ // Extract directory and filename
136
+ const dir = dirname(cleanRelative)
137
+ const filename = cleanRelative.split('/').pop()
138
+
139
+ // Strip timestamp prefix to restore original filename
140
+ const timestampRegex = /^(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)_/
141
+ const originalFilename = filename.replace(timestampRegex, '')
142
+
143
+ const finalLocalRelativePath = dir === '.' ? originalFilename : join(dir, originalFilename)
144
+ const localPath = join(config.sourceDir, finalLocalRelativePath)
145
+
146
+ // Ensure directory exists
147
+ await mkdir(dirname(localPath), { recursive: true })
148
+ await writeFile(localPath, byteArray)
149
+
150
+ return localPath
151
+ }
152
+
153
+ /**
154
+ * Delete a file from the remote bucket
155
+ * @param {string} key - S3 key of the file to delete
156
+ */
157
+ const deleteFile = async key => {
158
+ const s3 = getS3()
159
+ await s3.delete(key)
160
+ }
161
+
162
+ return {
163
+ uploadFile,
164
+ processDirectory,
165
+ listRemoteFiles,
166
+ restoreFile,
167
+ deleteFile,
168
+ }
169
+ }
@@ -0,0 +1,28 @@
1
+ import { S3Client } from 'bun'
2
+
3
+ /**
4
+ * Create an S3/R2 client with the given configuration
5
+ * @param {Object} config - Configuration object
6
+ * @param {string} config.bucket - Bucket name
7
+ * @param {string} config.endpoint - S3/R2 endpoint URL
8
+ * @param {string} config.accessKeyId - Access key ID
9
+ * @param {string} config.secretAccessKey - Secret access key
10
+ * @returns {S3Client} Configured S3 client
11
+ */
12
+ export const createS3Client = config => {
13
+ // Debug config (masked for security)
14
+ console.log('S3 Config:', {
15
+ bucket: config.bucket,
16
+ endpoint: config.endpoint,
17
+ accessKeyId: config.accessKeyId ? '***' + config.accessKeyId.slice(-4) : 'missing',
18
+ hasSecret: !!config.secretAccessKey,
19
+ })
20
+
21
+ return new S3Client({
22
+ accessKeyId: config.accessKeyId,
23
+ secretAccessKey: config.secretAccessKey,
24
+ endpoint: config.endpoint,
25
+ bucket: config.bucket,
26
+ region: 'auto',
27
+ })
28
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Dashboard page component - Modular version
3
+ * Composes all dashboard components
4
+ * @param {{ config: object, jobStatus: object, hasAuth: boolean }} props
5
+ * @returns {string} HTML string
6
+ */
7
+ import { Head } from './components/Head.js'
8
+ import { Header } from './components/Header.js'
9
+ import { StatusCards } from './components/StatusCards.js'
10
+ import { ActionArea } from './components/ActionArea.js'
11
+ import { FilesTab } from './components/FilesTab.js'
12
+ import { SettingsTab } from './components/SettingsTab.js'
13
+ import { SecuritySection } from './components/SecuritySection.js'
14
+ import { backupAppScript } from './scripts/backupApp.js'
15
+
16
+ export const DashboardPage = ({ config, jobStatus, hasAuth }) => `
17
+ <!DOCTYPE html>
18
+ <html lang="en">
19
+ <head>
20
+ ${Head({ title: 'R2 Backup Manager' })}
21
+ </head>
22
+ <body class="bg-gray-50 text-gray-900 min-h-screen p-12 antialiased selection:bg-gray-900 selection:text-white">
23
+ <div class="max-w-5xl mx-auto" x-data="backupApp()">
24
+ <!-- Header -->
25
+ ${Header({ hasAuth })}
26
+
27
+ <!-- Dashboard Tab -->
28
+ <div x-show="activeTab === 'dashboard'" class="space-y-12"
29
+ x-transition:enter="transition ease-out duration-300"
30
+ x-transition:enter-start="opacity-0 translate-y-2"
31
+ x-transition:enter-end="opacity-100 translate-y-0">
32
+
33
+ <!-- Status Cards -->
34
+ ${StatusCards()}
35
+
36
+ <!-- Action Area -->
37
+ ${ActionArea()}
38
+ </div>
39
+
40
+ <!-- Files Tab -->
41
+ ${FilesTab()}
42
+
43
+ <!-- Settings Tab -->
44
+ <div x-show="activeTab === 'settings'" class="max-w-3xl mx-auto"
45
+ x-transition:enter="transition ease-out duration-300"
46
+ x-transition:enter-start="opacity-0 translate-y-2"
47
+ x-transition:enter-end="opacity-100 translate-y-0">
48
+ ${SettingsTab()}
49
+ ${SecuritySection()}
50
+ </div>
51
+ </div>
52
+
53
+ ${backupAppScript({ config, jobStatus })}
54
+ </body>
55
+ </html>
56
+ `
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Login page component - Modular version
3
+ * Composes login components
4
+ * @param {{ totpEnabled: boolean }} props
5
+ * @returns {string} HTML string
6
+ */
7
+ import { Head } from './components/Head.js'
8
+ import { LoginCard } from './components/LoginCard.js'
9
+ import { loginAppScript } from './scripts/loginApp.js'
10
+
11
+ export const LoginPage = ({ totpEnabled = false }) => `
12
+ <!DOCTYPE html>
13
+ <html lang="en">
14
+ <head>
15
+ ${Head({ title: 'Login - Backup Manager' })}
16
+ </head>
17
+ <body class="bg-gray-50 min-h-screen flex items-center justify-center p-6 antialiased">
18
+ ${LoginCard({ totpEnabled })}
19
+
20
+ ${loginAppScript({ totpEnabled })}
21
+ </body>
22
+ </html>
23
+ `
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Action area component with backup button and activity logs
3
+ * @returns {string} HTML string
4
+ */
5
+ export const ActionArea = () => `
6
+ <div class="bg-white rounded-2xl border border-gray-100 shadow-[0_2px_8px_rgba(0,0,0,0.04)] overflow-hidden">
7
+ <div class="p-10 flex flex-col items-center justify-center text-center">
8
+ <div class="w-16 h-16 bg-gray-50 rounded-full flex items-center justify-center mb-6 text-gray-400">
9
+ <i data-lucide="save" class="w-8 h-8"></i>
10
+ </div>
11
+ <h3 class="text-xl font-bold text-gray-900 mb-2">Trigger Manual Backup</h3>
12
+ <p class="text-gray-500 max-w-md mb-8 leading-relaxed">Initiate a backup of your local directory to the configured R2 bucket. This will upload all files recursively.</p>
13
+
14
+ <button
15
+ @click="runBackup()"
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">
18
+ <span x-show="!loading" class="flex items-center gap-2">
19
+ <i data-lucide="play-circle" class="w-5 h-5"></i>
20
+ Start Backup Process
21
+ </span>
22
+ <span x-show="loading" class="flex items-center gap-2">
23
+ <i data-lucide="loader-2" class="w-5 h-5 animate-spin"></i>
24
+ Processing...
25
+ </span>
26
+ </button>
27
+ </div>
28
+
29
+ <!-- Logs -->
30
+ <div class="bg-gray-50 border-t border-gray-100 p-8">
31
+ <h4 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-4">Activity Log</h4>
32
+ <div class="space-y-3">
33
+ <template x-for="log in logs" :key="log.id">
34
+ <div class="flex items-start gap-4 text-sm">
35
+ <span class="font-mono text-xs text-gray-400 mt-0.5" x-text="log.time"></span>
36
+ <div class="flex items-center gap-2">
37
+ <i :data-lucide="log.type === 'error' ? 'alert-circle' : 'info'"
38
+ :class="log.type === 'error' ? 'text-red-600' : 'text-gray-400'"
39
+ class="w-4 h-4"></i>
40
+ <span :class="log.type === 'error' ? 'text-red-600 font-medium' : 'text-gray-600'" x-text="log.message"></span>
41
+ </div>
42
+ </div>
43
+ </template>
44
+ <div x-show="logs.length === 0" class="text-gray-400 text-sm italic">No recent activity recorded.</div>
45
+ </div>
46
+ </div>
47
+ </div>
48
+ `
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Files tab component with remote files table
3
+ * @returns {string} HTML string
4
+ */
5
+ export const FilesTab = () => `
6
+ <div x-show="activeTab === 'files'" class="bg-white rounded-2xl border border-gray-100 shadow-[0_2px_8px_rgba(0,0,0,0.04)] overflow-hidden"
7
+ x-transition:enter="transition ease-out duration-300"
8
+ x-transition:enter-start="opacity-0 translate-y-2"
9
+ x-transition:enter-end="opacity-100 translate-y-0">
10
+ <div class="p-8 border-b border-gray-100 flex justify-between items-center">
11
+ <div>
12
+ <h2 class="text-xl font-bold text-gray-900 flex items-center gap-2">
13
+ <i data-lucide="server" class="w-5 h-5 text-gray-400"></i>
14
+ Remote Files
15
+ </h2>
16
+ <p class="text-gray-500 text-sm mt-1">Files currently stored in your R2 bucket</p>
17
+ </div>
18
+ <button @click="fetchFiles()" class="text-gray-900 hover:text-gray-600 font-semibold text-sm transition-colors flex items-center gap-2">
19
+ <i data-lucide="refresh-cw" class="w-4 h-4"></i>
20
+ Refresh List
21
+ </button>
22
+ </div>
23
+ <div class="overflow-x-auto">
24
+ <table class="w-full text-left">
25
+ <thead>
26
+ <tr class="border-b border-gray-100">
27
+ <th class="px-8 py-4 text-xs font-bold text-gray-400 uppercase tracking-wider">File Key</th>
28
+ <th class="px-8 py-4 text-xs font-bold text-gray-400 uppercase tracking-wider">Size</th>
29
+ <th class="px-8 py-4 text-xs font-bold text-gray-400 uppercase tracking-wider">Last Modified</th>
30
+ <th class="px-8 py-4 text-xs font-bold text-gray-400 uppercase tracking-wider text-right">Action</th>
31
+ </tr>
32
+ </thead>
33
+ <template x-for="group in groups" :key="group.name">
34
+ <tbody class="divide-y divide-gray-50 border-b border-gray-100">
35
+ <!-- Folder Header -->
36
+ <tr class="bg-gray-50 hover:bg-gray-100 cursor-pointer transition-colors select-none"
37
+ @click="group.expanded = !group.expanded; $nextTick(() => lucide.createIcons())">
38
+ <td colspan="4" class="px-8 py-3">
39
+ <div class="flex items-center gap-3">
40
+ <i :data-lucide="group.expanded ? 'folder-open' : 'folder'" class="w-5 h-5 text-gray-400"></i>
41
+ <span class="font-bold text-gray-700 text-sm" x-text="formatDateHeader(group.name)"></span>
42
+ <span class="text-xs font-medium text-gray-500 bg-gray-200 px-2 py-0.5 rounded-full" x-text="group.files.length"></span>
43
+ </div>
44
+ </td>
45
+ </tr>
46
+
47
+ <!-- Files in Group -->
48
+ <template x-for="file in group.files" :key="file.key">
49
+ <tr x-show="group.expanded" class="hover:bg-gray-50 transition-colors duration-150 group bg-white">
50
+ <td class="px-8 py-5 font-medium text-gray-900 text-sm pl-12" x-text="file.key"></td>
51
+ <td class="px-8 py-5 text-gray-500 text-sm" x-text="formatBytes(file.size)"></td>
52
+ <td class="px-8 py-5 text-gray-500 text-sm" x-text="new Date(file.lastModified).toLocaleString()"></td>
53
+ <td class="px-8 py-5 text-right">
54
+ <div class="flex justify-end gap-3 items-center">
55
+ <!-- Restore Button -->
56
+ <button
57
+ x-data="holdButton(() => restoreFile(file.key))"
58
+ @mousedown="start()" @touchstart.prevent="start()"
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"
61
+ title="Hold 3s to Restore"
62
+ >
63
+ <div class="absolute inset-0 bg-blue-200/50 origin-left transition-all duration-0 ease-linear" :style="'width: ' + progress + '%'"></div>
64
+ <span class="relative z-10 flex items-center gap-2">
65
+ <i data-lucide="rotate-ccw" class="w-3 h-3"></i>
66
+ <span x-text="progress > 0 ? 'Hold...' : 'Restore'"></span>
67
+ </span>
68
+ </button>
69
+
70
+ <!-- Delete Button -->
71
+ <button
72
+ x-data="holdButton(() => deleteFile(file.key))"
73
+ @mousedown="start()" @touchstart.prevent="start()"
74
+ @mouseup="stop()" @mouseleave="stop()" @touchend="stop()"
75
+ class="relative overflow-hidden px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider text-red-600 bg-red-50 hover:bg-red-100 transition-colors select-none"
76
+ title="Hold 3s to Delete"
77
+ >
78
+ <div class="absolute inset-0 bg-red-200/50 origin-left transition-all duration-0 ease-linear" :style="'width: ' + progress + '%'"></div>
79
+ <span class="relative z-10 flex items-center gap-2">
80
+ <i data-lucide="trash-2" class="w-3 h-3"></i>
81
+ <span x-text="progress > 0 ? 'Hold...' : 'Delete'"></span>
82
+ </span>
83
+ </button>
84
+ </div>
85
+ </td>
86
+ </tr>
87
+ </template>
88
+ </tbody>
89
+ </template>
90
+
91
+ <!-- Empty State -->
92
+ <tbody x-show="groups.length === 0">
93
+ <tr>
94
+ <td colspan="4" class="px-8 py-16 text-center">
95
+ <div class="flex flex-col items-center justify-center">
96
+ <span x-show="!loadingFiles" class="text-gray-500 font-medium flex flex-col items-center gap-2">
97
+ <i data-lucide="inbox" class="w-8 h-8 text-gray-300"></i>
98
+ No files found in bucket
99
+ </span>
100
+ <span x-show="loadingFiles" class="text-gray-500 font-medium animate-pulse flex flex-col items-center gap-2">
101
+ <i data-lucide="loader" class="w-8 h-8 animate-spin text-gray-300"></i>
102
+ Loading remote files...
103
+ </span>
104
+ </div>
105
+ </td>
106
+ </tr>
107
+ </tbody>
108
+ </table>
109
+ </div>
110
+ </div>
111
+ `
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Shared HTML head component
3
+ * Contains Tailwind config, Alpine.js, Lucide icons, and fonts
4
+ * @param {{ title: string }} props
5
+ * @returns {string} HTML string for head section
6
+ */
7
+ export const Head = ({ title = 'Backup Manager' }) => `
8
+ <meta charset="UTF-8">
9
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
10
+ <title>${title}</title>
11
+ <script src="https://cdn.tailwindcss.com"></script>
12
+ <script>
13
+ tailwind.config = {
14
+ theme: {
15
+ extend: {
16
+ fontFamily: {
17
+ sans: ['Montserrat', 'sans-serif'],
18
+ },
19
+ colors: {
20
+ gray: {
21
+ 50: '#F9FAFB',
22
+ 100: '#F3F4F6',
23
+ 200: '#E5E7EB',
24
+ 300: '#D1D5DB',
25
+ 400: '#9CA3AF',
26
+ 500: '#6B7280',
27
+ 600: '#4B5563',
28
+ 700: '#374151',
29
+ 800: '#1F2937',
30
+ 900: '#111827',
31
+ }
32
+ }
33
+ }
34
+ }
35
+ }
36
+ </script>
37
+ <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
38
+ <script src="https://unpkg.com/lucide@latest"></script>
39
+ <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap" rel="stylesheet">
40
+ <style>
41
+ [x-cloak] { display: none !important; }
42
+ </style>
43
+ `
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Dashboard header component with tabs
3
+ * @param {{ hasAuth: boolean }} props
4
+ * @returns {string} HTML string
5
+ */
6
+ export const Header = ({ hasAuth }) => `
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>
16
+ </div>
17
+
18
+ <div class="flex items-center gap-4">
19
+ <!-- Tabs -->
20
+ <div class="flex p-1 bg-gray-200/50 rounded-xl">
21
+ <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'"
23
+ class="px-6 py-2.5 rounded-lg text-sm font-semibold transition-all duration-200 flex items-center gap-2">
24
+ <i data-lucide="layout-dashboard" class="w-4 h-4"></i>
25
+ Overview
26
+ </button>
27
+ <button @click="activeTab = 'files'; fetchFiles()"
28
+ :class="activeTab === 'files' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
29
+ class="px-6 py-2.5 rounded-lg text-sm font-semibold transition-all duration-200 flex items-center gap-2">
30
+ <i data-lucide="folder-open" class="w-4 h-4"></i>
31
+ Files & Restore
32
+ </button>
33
+ <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'"
35
+ class="px-6 py-2.5 rounded-lg text-sm font-semibold transition-all duration-200 flex items-center gap-2">
36
+ <i data-lucide="settings" class="w-4 h-4"></i>
37
+ Settings
38
+ </button>
39
+ </div>
40
+
41
+ ${
42
+ hasAuth
43
+ ? `
44
+ <!-- Logout Button -->
45
+ <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">
46
+ <i data-lucide="log-out" class="w-4 h-4"></i>
47
+ <span>Logout</span>
48
+ </button>
49
+ `
50
+ : ''
51
+ }
52
+ </div>
53
+ </div>
54
+ `
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Login card component
3
+ * @param {{ totpEnabled: boolean }} props
4
+ * @returns {string} HTML string
5
+ */
6
+ export const LoginCard = ({ totpEnabled }) => `
7
+ <div class="w-full max-w-md" x-data="loginApp()">
8
+ <!-- Login 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-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>
15
+ <h1 class="text-2xl font-bold text-gray-900 mb-2">Backup Manager</h1>
16
+ <p class="text-sm text-gray-500">Access Control Panel</p>
17
+ </div>
18
+
19
+ <!-- Form -->
20
+ <div class="p-10">
21
+ <form @submit.prevent="login" class="space-y-6">
22
+ <!-- Error Message -->
23
+ <div x-show="error" x-cloak class="bg-red-50 border border-red-200 rounded-lg p-4">
24
+ <div class="flex items-center gap-3">
25
+ <i data-lucide="alert-circle" class="w-5 h-5 text-red-600"></i>
26
+ <span class="text-sm text-red-800 font-medium" x-text="error"></span>
27
+ </div>
28
+ </div>
29
+
30
+ <!-- Username -->
31
+ <div>
32
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Username</label>
33
+ <div class="relative">
34
+ <div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
35
+ <i data-lucide="user" class="w-5 h-5 text-gray-400"></i>
36
+ </div>
37
+ <input
38
+ type="text"
39
+ x-model="username"
40
+ 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"
42
+ placeholder="Enter your username"
43
+ autofocus
44
+ >
45
+ </div>
46
+ </div>
47
+
48
+ <!-- Password -->
49
+ <div>
50
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Password</label>
51
+ <div class="relative">
52
+ <div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
53
+ <i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
54
+ </div>
55
+ <input
56
+ type="password"
57
+ x-model="password"
58
+ 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"
60
+ placeholder="Enter your password"
61
+ >
62
+ </div>
63
+ </div>
64
+
65
+ <!-- TOTP Code (only shown when TOTP is enabled) -->
66
+ <div x-show="totpEnabled" x-cloak>
67
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Authenticator Code</label>
68
+ <div class="relative">
69
+ <div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
70
+ <i data-lucide="smartphone" class="w-5 h-5 text-gray-400"></i>
71
+ </div>
72
+ <input
73
+ type="text"
74
+ x-model="totpCode"
75
+ inputmode="numeric"
76
+ pattern="[0-9]*"
77
+ maxlength="6"
78
+ :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"
80
+ placeholder="000000"
81
+ >
82
+ </div>
83
+ <p class="text-xs text-gray-500 mt-2 flex items-center gap-1">
84
+ <i data-lucide="info" class="w-3 h-3"></i>
85
+ Enter the 6-digit code from your authenticator app
86
+ </p>
87
+ </div>
88
+
89
+ <!-- Submit Button -->
90
+ <button
91
+ type="submit"
92
+ :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"
94
+ >
95
+ <span x-show="!loading" class="flex items-center gap-2">
96
+ <span>Sign In</span>
97
+ <i data-lucide="arrow-right" class="w-5 h-5"></i>
98
+ </span>
99
+ <span x-show="loading" class="flex items-center gap-2">
100
+ <i data-lucide="loader-2" class="w-5 h-5 animate-spin"></i>
101
+ <span>Authenticating...</span>
102
+ </span>
103
+ </button>
104
+ </form>
105
+ </div>
106
+ </div>
107
+
108
+ <!-- Footer -->
109
+ <div class="text-center mt-6 text-sm text-gray-500">
110
+ <i data-lucide="info" class="w-4 h-4 inline-block mr-1"></i>
111
+ Secure connection required for production use
112
+ </div>
113
+ </div>
114
+ `