diff --git a/public/app.js b/public/app.js index 6813560..c5938dc 100644 --- a/public/app.js +++ b/public/app.js @@ -100,6 +100,16 @@ const elements = { btnRefreshCloudModal: document.getElementById('btn-refresh-cloud-modal'), cloudRestoreModalStatus: document.getElementById('cloud-restore-modal-status'), cloudRestoreFilesContainer: document.getElementById('cloud-restore-files-container'), + restoreScopeModal: document.getElementById('restore-scope-modal'), + restoreScopeBackupSummary: document.getElementById('restore-scope-backup-summary'), + restoreScopeCardAll: document.getElementById('restore-scope-card-all'), + restoreScopeAll: document.getElementById('restore-scope-all'), + restoreScopeAllDesc: document.getElementById('restore-scope-all-desc'), + restoreScopeCardSingle: document.getElementById('restore-scope-card-single'), + restoreScopeSingle: document.getElementById('restore-scope-single'), + restoreTargetUserSelect: document.getElementById('restore-target-user-select'), + restoreScopeStatus: document.getElementById('restore-scope-status'), + btnConfirmScopedRestore: document.getElementById('btn-confirm-scoped-restore'), adminBackupsHistoryList: document.getElementById('admin-backups-history-list'), // Admin Create User Modal @@ -346,6 +356,15 @@ function registerEvents() { if (elements.cloudRestoreProviderFilter) { elements.cloudRestoreProviderFilter.addEventListener('change', () => renderCloudRestoreModalList()); } + if (elements.restoreScopeAll) { + elements.restoreScopeAll.addEventListener('change', updateRestoreScopeModeUI); + } + if (elements.restoreScopeSingle) { + elements.restoreScopeSingle.addEventListener('change', updateRestoreScopeModeUI); + } + if (elements.btnConfirmScopedRestore) { + elements.btnConfirmScopedRestore.addEventListener('click', executeScopedRestore); + } } window.addEventListener('message', (event) => { @@ -3658,29 +3677,12 @@ async function downloadCloudBackupItem(id, format, fallbackName) { } async function restoreCloudBackupItem(id, dateLabel) { - const confirmed = window.confirm( - `Are you sure you want to restore the entire ForkFolio database from the cloud backup taken on ${dateLabel}?\n\nThis will overwrite current users, recipes, and shopping lists with the snapshot.` - ); - if (!confirmed) return; - - try { - showBackupStatus('Downloading and restoring database from cloud snapshot...', 'info'); - const res = await fetch(`/api/admin/backups/${id}/restore-cloud`, { - method: 'POST', - headers: getAuthHeaders() - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || 'Cloud restore failed.'); - showBackupStatus( - `Database restored (${data.restored.userCount} users, ${data.restored.recipeCount} recipes, ${data.restored.shoppingListCount} shopping lists)!`, - 'success' - ); - await fetchRecipes(); - await fetchShoppingLists(); - fetchAdminBackupConfig(); - } catch (err) { - showBackupStatus(err.message, 'error'); - } + openScopedRestoreModal({ + mode: 'history', + historyId: id, + format: 'db', + titleLabel: `Cloud Backup (${dateLabel})` + }); } async function deleteCloudBackupItem(id, dateLabel) { @@ -3706,13 +3708,8 @@ async function handleRestoreLocalFile(e) { e.target.value = ''; if (!file) return; - const confirmed = window.confirm( - `Are you sure you want to restore the entire ForkFolio database from "${file.name}"?\n\nThis will replace current users, recipes, images, and shopping lists with the contents of this backup file.` - ); - if (!confirmed) return; - try { - showBackupStatus(`Uploading and restoring "${file.name}"...`, 'info'); + showBackupStatus(`Reading "${file.name}"...`, 'info'); const base64Data = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { @@ -3724,23 +3721,12 @@ async function handleRestoreLocalFile(e) { reader.readAsDataURL(file); }); - const res = await fetch('/api/admin/backups/restore-upload', { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ - filename: file.name, - dataBase64: base64Data - }) + openScopedRestoreModal({ + mode: 'upload', + filename: file.name, + base64Data, + titleLabel: `Local File: "${file.name}"` }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || 'Failed to restore from uploaded file.'); - showBackupStatus( - `Restored from "${file.name}" (${data.restored.userCount} users, ${data.restored.recipeCount} recipes, ${data.restored.shoppingListCount} shopping lists)!`, - 'success' - ); - await fetchRecipes(); - await fetchShoppingLists(); - fetchAdminBackupConfig(); } catch (err) { showBackupStatus(err.message, 'error'); } @@ -3751,6 +3737,7 @@ async function handleRestoreLocalFile(e) { // ========================================== let cachedCloudBackups = []; +let activeRestoreScopeContext = null; function showCloudRestoreModalStatus(message, type = 'info') { const box = elements.cloudRestoreModalStatus; @@ -3772,6 +3759,209 @@ function showCloudRestoreModalStatus(message, type = 'info') { box.textContent = message; } +function showRestoreScopeStatus(message, type = 'info') { + const box = elements.restoreScopeStatus; + if (!box) return; + box.classList.remove('hidden'); + if (type === 'success') { + box.style.background = 'rgba(16, 185, 129, 0.12)'; + box.style.border = '1px solid rgba(16, 185, 129, 0.35)'; + box.style.color = '#10b981'; + } else if (type === 'error') { + box.style.background = 'rgba(239, 68, 68, 0.12)'; + box.style.border = '1px solid rgba(239, 68, 68, 0.35)'; + box.style.color = '#ef4444'; + } else { + box.style.background = 'rgba(59, 130, 246, 0.12)'; + box.style.border = '1px solid rgba(59, 130, 246, 0.35)'; + box.style.color = '#3b82f6'; + } + box.textContent = message; +} + +function updateRestoreScopeModeUI() { + const isSingle = elements.restoreScopeSingle && elements.restoreScopeSingle.checked; + if (elements.restoreScopeCardAll) { + elements.restoreScopeCardAll.style.borderColor = isSingle ? 'var(--border)' : 'var(--accent)'; + } + if (elements.restoreScopeCardSingle) { + elements.restoreScopeCardSingle.style.borderColor = isSingle ? 'var(--accent)' : 'var(--border)'; + } + if (elements.restoreTargetUserSelect) { + elements.restoreTargetUserSelect.disabled = !isSingle; + } +} + +async function openScopedRestoreModal(context) { + activeRestoreScopeContext = context; + if (elements.restoreScopeStatus) { + elements.restoreScopeStatus.classList.add('hidden'); + } + if (elements.restoreScopeAll) elements.restoreScopeAll.checked = true; + if (elements.restoreScopeSingle) elements.restoreScopeSingle.checked = false; + updateRestoreScopeModeUI(); + + if (elements.restoreScopeBackupSummary) { + elements.restoreScopeBackupSummary.innerHTML = `Source: ${context.titleLabel}
Inspecting users and recipes inside backup...`; + } + if (elements.restoreTargetUserSelect) { + elements.restoreTargetUserSelect.innerHTML = ``; + } + + openModal('restore-scope-modal'); + lucide.createIcons(); + + try { + const inspectBody = {}; + if (context.mode === 'upload') { + inspectBody.filename = context.filename; + inspectBody.base64Data = context.base64Data; + } else if (context.mode === 'direct') { + inspectBody.provider = context.provider; + inspectBody.fileId = context.fileId; + inspectBody.objectKey = context.objectKey; + inspectBody.filename = context.filename; + } else if (context.mode === 'history') { + inspectBody.historyId = context.historyId; + inspectBody.format = context.format || 'db'; + } + + const res = await fetch('/api/admin/backups/inspect', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify(inspectBody) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to inspect backup contents.'); + + if (elements.restoreScopeBackupSummary) { + elements.restoreScopeBackupSummary.innerHTML = ` +
${context.titleLabel}
+
Contains ${data.totalUsers} users · ${data.totalRecipes} recipes · ${data.totalShoppingLists} shopping lists
+ `; + } + if (elements.restoreScopeAllDesc) { + elements.restoreScopeAllDesc.textContent = `Replaces all users (${data.totalUsers}), recipes (${data.totalRecipes}), images, and shopping lists (${data.totalShoppingLists}) with this backup.`; + } + + if (elements.restoreTargetUserSelect) { + elements.restoreTargetUserSelect.innerHTML = ''; + const users = Array.isArray(data.users) ? data.users : []; + if (users.length === 0) { + elements.restoreTargetUserSelect.innerHTML = ``; + } else { + users.forEach(u => { + const opt = document.createElement('option'); + opt.value = u.username; + const roleLabel = u.isAdmin ? ' (Admin)' : u.isRecipeManager ? ' (Manager)' : ''; + opt.textContent = `@${u.username}${roleLabel} — ${u.recipeCount} ${u.recipeCount === 1 ? 'recipe' : 'recipes'}, ${u.shoppingListCount} ${u.shoppingListCount === 1 ? 'shopping list' : 'shopping lists'}`; + elements.restoreTargetUserSelect.appendChild(opt); + }); + } + } + } catch (err) { + if (elements.restoreScopeBackupSummary) { + elements.restoreScopeBackupSummary.innerHTML = `
${context.titleLabel}
`; + } + showRestoreScopeStatus(`Could not pre-inspect user list: ${err.message}`, 'error'); + } +} + +async function executeScopedRestore() { + if (!activeRestoreScopeContext) return; + const isSingle = elements.restoreScopeSingle && elements.restoreScopeSingle.checked; + const targetUser = isSingle + ? (elements.restoreTargetUserSelect ? elements.restoreTargetUserSelect.value : '') + : 'all'; + + if (isSingle && !targetUser) { + showRestoreScopeStatus('Please select which user to restore from the dropdown.', 'error'); + return; + } + + const btn = elements.btnConfirmScopedRestore; + const origHtml = btn ? btn.innerHTML : ''; + if (btn) { + btn.disabled = true; + btn.innerHTML = ` Restoring...`; + lucide.createIcons(); + } + + try { + showRestoreScopeStatus( + isSingle + ? `Restoring only @${targetUser}'s recipes, images, and shopping lists...` + : 'Restoring entire database (all users, recipes, and shopping lists)...', + 'info' + ); + + const ctx = activeRestoreScopeContext; + let res; + if (ctx.mode === 'upload') { + res = await fetch('/api/admin/backups/restore-upload', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ + filename: ctx.filename, + dataBase64: ctx.base64Data, + targetUser + }) + }); + } else if (ctx.mode === 'direct') { + res = await fetch('/api/admin/backups/restore-cloud-file', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ + provider: ctx.provider, + fileId: ctx.fileId, + objectKey: ctx.objectKey, + filename: ctx.filename, + targetUser + }) + }); + } else { + res = await fetch(`/api/admin/backups/${ctx.historyId}/restore-cloud`, { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ + format: ctx.format || 'db', + targetUser + }) + }); + } + + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Restore failed.'); + + const r = data.restored || {}; + const msg = + r.mode === 'single_user' + ? `✓ Restored data for @${r.targetUsername} (${r.recipeCount} recipes, ${r.shoppingListCount} shopping lists) without modifying other users!` + : `✓ Full database restored (${r.userCount} users, ${r.recipeCount} recipes, ${r.shoppingListCount} shopping lists)!`; + + showRestoreScopeStatus(msg, 'success'); + showBackupStatus(msg, 'success'); + + await fetchRecipes(); + await fetchShoppingLists(); + await fetchAdminBackupConfig(); + + setTimeout(() => { + closeModal('restore-scope-modal'); + closeModal('restore-cloud-modal'); + }, 1100); + } catch (err) { + showRestoreScopeStatus(`Restore failed: ${err.message}`, 'error'); + showBackupStatus(`Restore failed: ${err.message}`, 'error'); + } finally { + if (btn) { + btn.disabled = false; + btn.innerHTML = origHtml; + lucide.createIcons(); + } + } +} + async function handleSyncCloudBackups(silent = false) { const btn = elements.btnSyncCloudBackups; const origHtml = btn ? btn.innerHTML : ''; @@ -3907,12 +4097,12 @@ function renderCloudRestoreModalList() {
${hasDb ? ` - ` : ''} ${hasJson ? ` - ` : ''} @@ -3921,12 +4111,12 @@ function renderCloudRestoreModalList() { const btnRestoreDb = card.querySelector('.btn-modal-restore-db'); if (btnRestoreDb) { - btnRestoreDb.addEventListener('click', () => handleRestoreDirectCloudFile(snap, 'db', dateStr, btnRestoreDb)); + btnRestoreDb.addEventListener('click', () => handleRestoreDirectCloudFile(snap, 'db', dateStr)); } const btnRestoreJson = card.querySelector('.btn-modal-restore-json'); if (btnRestoreJson) { - btnRestoreJson.addEventListener('click', () => handleRestoreDirectCloudFile(snap, 'json', dateStr, btnRestoreJson)); + btnRestoreJson.addEventListener('click', () => handleRestoreDirectCloudFile(snap, 'json', dateStr)); } elements.cloudRestoreFilesContainer.appendChild(card); @@ -3935,71 +4125,34 @@ function renderCloudRestoreModalList() { lucide.createIcons(); } -async function handleRestoreDirectCloudFile(snap, format, dateLabel, btnEl) { - const confirmed = window.confirm( - `Are you sure you want to restore your ForkFolio database from the cloud ${format.toUpperCase()} backup created on ${dateLabel}?\n\nThis will replace current users, recipes, images, and shopping lists with the contents of this cloud backup.` - ); - if (!confirmed) return; +function handleRestoreDirectCloudFile(snap, format, dateLabel) { + const gdriveId = format === 'json' ? (snap.gdriveFileIdJson || snap.gdriveFileIdDb) : (snap.gdriveFileIdDb || snap.gdriveFileIdJson); + const s3Key = format === 'json' ? (snap.s3KeyJson || snap.s3KeyDb) : (snap.s3KeyDb || snap.s3KeyJson); + const filename = format === 'json' ? snap.filenameJson : snap.filenameDb; - const origHtml = btnEl ? btnEl.innerHTML : ''; - if (btnEl) { - btnEl.disabled = true; - btnEl.innerHTML = ` Restoring...`; - lucide.createIcons(); - } - - try { - showCloudRestoreModalStatus(`Downloading ${format.toUpperCase()} backup from cloud and restoring database...`, 'info'); - - // Determine provider & identifier - const gdriveId = format === 'json' ? (snap.gdriveFileIdJson || snap.gdriveFileIdDb) : (snap.gdriveFileIdDb || snap.gdriveFileIdJson); - const s3Key = format === 'json' ? (snap.s3KeyJson || snap.s3KeyDb) : (snap.s3KeyDb || snap.s3KeyJson); - const filename = format === 'json' ? snap.filenameJson : snap.filenameDb; - - let res; - if (gdriveId) { - res = await fetch('/api/admin/backups/restore-cloud-file', { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ provider: 'gdrive', fileId: gdriveId, filename }) - }); - } else if (s3Key) { - res = await fetch('/api/admin/backups/restore-cloud-file', { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ provider: 's3', objectKey: s3Key, filename }) - }); - } else { - res = await fetch(`/api/admin/backups/${snap.id}/restore-cloud`, { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ format }) - }); - } - - const data = await res.json(); - if (!res.ok) throw new Error(data.error || 'Cloud restore failed.'); - - const msg = `✓ Database restored from cloud (${data.restored.userCount} users, ${data.restored.recipeCount} recipes, ${data.restored.shoppingListCount} shopping lists)!`; - showCloudRestoreModalStatus(msg, 'success'); - showBackupStatus(msg, 'success'); - - await fetchRecipes(); - await fetchShoppingLists(); - await fetchAdminBackupConfig(); - - setTimeout(() => { - closeModal('restore-cloud-modal'); - }, 1400); - } catch (err) { - showCloudRestoreModalStatus(`Restore failed: ${err.message}`, 'error'); - showBackupStatus(`Restore failed: ${err.message}`, 'error'); - } finally { - if (btnEl) { - btnEl.disabled = false; - btnEl.innerHTML = origHtml; - lucide.createIcons(); - } + if (gdriveId) { + openScopedRestoreModal({ + mode: 'direct', + provider: 'gdrive', + fileId: gdriveId, + filename, + titleLabel: `Google Drive (${dateLabel} · .${format.toUpperCase()})` + }); + } else if (s3Key) { + openScopedRestoreModal({ + mode: 'direct', + provider: 's3', + objectKey: s3Key, + filename, + titleLabel: `Wasabi S3 (${dateLabel} · .${format.toUpperCase()})` + }); + } else { + openScopedRestoreModal({ + mode: 'history', + historyId: snap.id, + format, + titleLabel: `Cloud Backup (${dateLabel} · .${format.toUpperCase()})` + }); } } diff --git a/public/index.html b/public/index.html index 67bd892..1cdbc18 100644 --- a/public/index.html +++ b/public/index.html @@ -574,6 +574,57 @@
+ + +