feat: add admin cloud and manual database backup and recovery console

This commit is contained in:
Chris 2026-10-31 00:23:35 -04:00
parent 01ed916a51
commit 90373d3cc3
4 changed files with 2113 additions and 17 deletions

View file

@ -51,10 +51,44 @@ const elements = {
btnAdminAddUser: document.getElementById('btn-admin-add-user'),
btnAdminTabUsers: document.getElementById('btn-admin-tab-users'),
btnAdminTabShares: document.getElementById('btn-admin-tab-shares'),
btnAdminTabBackups: document.getElementById('btn-admin-tab-backups'),
adminUsersSection: document.getElementById('admin-users-section'),
adminSharesSection: document.getElementById('admin-shares-section'),
adminBackupsSection: document.getElementById('admin-backups-section'),
adminSharesList: document.getElementById('admin-shares-list'),
// Admin Backups Console Elements
btnBackupRunCloud: document.getElementById('btn-backup-run-cloud'),
btnBackupDownloadDb: document.getElementById('btn-backup-download-db'),
btnBackupDownloadJson: document.getElementById('btn-backup-download-json'),
backupRestoreFileInput: document.getElementById('backup-restore-file-input'),
btnBackupRestoreLocal: document.getElementById('btn-backup-restore-local'),
backupStatusBanner: document.getElementById('backup-status-banner'),
adminBackupConfigForm: document.getElementById('admin-backup-config-form'),
backupLastRunBadge: document.getElementById('backup-last-run-badge'),
backupScheduleFrequency: document.getElementById('backup-schedule-frequency'),
backupScheduleTime: document.getElementById('backup-schedule-time'),
backupScheduleDay: document.getElementById('backup-schedule-day'),
backupRetentionCount: document.getElementById('backup-retention-count'),
backupFormat: document.getElementById('backup-format'),
backupEmbedImages: document.getElementById('backup-embed-images'),
backupS3Enabled: document.getElementById('backup-s3-enabled'),
backupS3Endpoint: document.getElementById('backup-s3-endpoint'),
backupS3Region: document.getElementById('backup-s3-region'),
backupS3Bucket: document.getElementById('backup-s3-bucket'),
backupS3Prefix: document.getElementById('backup-s3-prefix'),
backupS3AccessKey: document.getElementById('backup-s3-access-key'),
backupS3SecretKey: document.getElementById('backup-s3-secret-key'),
btnBackupTestS3: document.getElementById('btn-backup-test-s3'),
backupGdriveEnabled: document.getElementById('backup-gdrive-enabled'),
backupGdriveClientId: document.getElementById('backup-gdrive-client-id'),
backupGdriveClientSecret: document.getElementById('backup-gdrive-client-secret'),
backupGdriveRefreshToken: document.getElementById('backup-gdrive-refresh-token'),
backupGdriveFolderId: document.getElementById('backup-gdrive-folder-id'),
btnBackupAuthorizeGdrive: document.getElementById('btn-backup-authorize-gdrive'),
btnBackupTestGdrive: document.getElementById('btn-backup-test-gdrive'),
adminBackupsHistoryList: document.getElementById('admin-backups-history-list'),
// Admin Create User Modal
adminCreateUserModal: document.getElementById('admin-create-user-modal'),
adminCreateUserForm: document.getElementById('admin-create-user-form'),
@ -260,6 +294,29 @@ function registerEvents() {
// Admin Sub-Tab Switchers
elements.btnAdminTabUsers.addEventListener('click', () => switchAdminSubTab('users'));
elements.btnAdminTabShares.addEventListener('click', () => switchAdminSubTab('shares'));
if (elements.btnAdminTabBackups) {
elements.btnAdminTabBackups.addEventListener('click', () => switchAdminSubTab('backups'));
}
// Admin Backups Console Actions
if (elements.adminBackupConfigForm) {
elements.adminBackupConfigForm.addEventListener('submit', handleSaveBackupConfig);
elements.btnBackupRunCloud.addEventListener('click', handleRunCloudBackupNow);
elements.btnBackupDownloadDb.addEventListener('click', () => handleDownloadLocalBackup('db'));
elements.btnBackupDownloadJson.addEventListener('click', () => handleDownloadLocalBackup('json'));
elements.btnBackupRestoreLocal.addEventListener('click', () => elements.backupRestoreFileInput.click());
elements.backupRestoreFileInput.addEventListener('change', handleRestoreLocalFile);
elements.btnBackupTestS3.addEventListener('click', handleTestS3Connection);
elements.btnBackupTestGdrive.addEventListener('click', handleTestGdriveConnection);
elements.btnBackupAuthorizeGdrive.addEventListener('click', handleAuthorizeGoogleDrive);
}
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'GDRIVE_OAUTH_SUCCESS') {
showBackupStatus('Google Drive account authorized and Refresh Token saved!', 'success');
fetchAdminBackupConfig();
}
});
// Servings Scaler Buttons
elements.scalerMinus.addEventListener('click', () => adjustMultiplier(-0.5));
@ -1530,24 +1587,31 @@ function renderAdminUsers(users) {
}
function switchAdminSubTab(subTabName) {
if (subTabName === 'users') {
elements.btnAdminTabUsers.classList.add('btn-primary');
elements.btnAdminTabUsers.classList.remove('btn-outline');
elements.btnAdminTabShares.classList.add('btn-outline');
elements.btnAdminTabShares.classList.remove('btn-primary');
const tabs = [
{ name: 'users', btn: elements.btnAdminTabUsers, section: elements.adminUsersSection },
{ name: 'shares', btn: elements.btnAdminTabShares, section: elements.adminSharesSection },
{ name: 'backups', btn: elements.btnAdminTabBackups, section: elements.adminBackupsSection }
];
elements.adminUsersSection.style.display = 'block';
elements.adminSharesSection.style.display = 'none';
tabs.forEach(t => {
if (!t.btn || !t.section) return;
if (t.name === subTabName) {
t.btn.classList.add('btn-primary');
t.btn.classList.remove('btn-outline');
t.section.style.display = 'block';
} else {
t.btn.classList.add('btn-outline');
t.btn.classList.remove('btn-primary');
t.section.style.display = 'none';
}
});
if (subTabName === 'users') {
fetchAdminUsers();
} else if (subTabName === 'shares') {
elements.btnAdminTabShares.classList.add('btn-primary');
elements.btnAdminTabShares.classList.remove('btn-outline');
elements.btnAdminTabUsers.classList.add('btn-outline');
elements.btnAdminTabUsers.classList.remove('btn-primary');
elements.adminUsersSection.style.display = 'none';
elements.adminSharesSection.style.display = 'block';
fetchAdminSharedRecipes();
} else if (subTabName === 'backups') {
fetchAdminBackupConfig();
}
lucide.createIcons();
}
@ -2919,4 +2983,470 @@ function renderShoppingListsWorkspace() {
lucide.createIcons();
}
// ==========================================
// ADMIN DATABASE BACKUPS & RECOVERY CONSOLE
// ==========================================
let backupStatusTimeout = null;
function showBackupStatus(message, type = 'info') {
if (!elements.backupStatusAlert) return;
if (backupStatusTimeout) clearTimeout(backupStatusTimeout);
elements.backupStatusAlert.className = `backup-status-alert status-${type}`;
const icon = type === 'success' ? 'check-circle-2' : type === 'error' ? 'alert-triangle' : 'info';
elements.backupStatusAlert.innerHTML = `<i data-lucide="${icon}" style="width:18px;height:18px;flex-shrink:0;"></i><span>${message}</span>`;
elements.backupStatusAlert.classList.remove('hidden');
lucide.createIcons();
if (type !== 'error') {
backupStatusTimeout = setTimeout(() => {
elements.backupStatusAlert.classList.add('hidden');
}, 8000);
}
}
function getBackupFormPayload() {
return {
schedule: elements.backupSchedule ? elements.backupSchedule.value : 'off',
scheduleTime: elements.backupScheduleTime ? elements.backupScheduleTime.value : '02:00',
retentionCount: elements.backupRetentionCount ? Number(elements.backupRetentionCount.value) || 14 : 14,
includeRemoteImages: elements.backupIncludeRemoteImages ? elements.backupIncludeRemoteImages.checked : true,
s3Enabled: elements.backupS3Enabled ? elements.backupS3Enabled.checked : false,
s3Endpoint: elements.backupS3Endpoint ? elements.backupS3Endpoint.value.trim() : 'https://s3.wasabisys.com',
s3Region: elements.backupS3Region ? elements.backupS3Region.value.trim() : 'us-east-1',
s3Bucket: elements.backupS3Bucket ? elements.backupS3Bucket.value.trim() : '',
s3Prefix: elements.backupS3Prefix ? elements.backupS3Prefix.value.trim() : 'forkfolio-backups/',
s3AccessKeyId: elements.backupS3AccessKey ? elements.backupS3AccessKey.value.trim() : '',
s3SecretAccessKey: elements.backupS3SecretKey ? elements.backupS3SecretKey.value.trim() : '',
gdriveEnabled: elements.backupGdriveEnabled ? elements.backupGdriveEnabled.checked : false,
gdriveClientId: elements.backupGdriveClientId ? elements.backupGdriveClientId.value.trim() : '',
gdriveClientSecret: elements.backupGdriveClientSecret ? elements.backupGdriveClientSecret.value.trim() : '',
gdriveRefreshToken: elements.backupGdriveRefreshToken ? elements.backupGdriveRefreshToken.value.trim() : '',
gdriveFolderId: elements.backupGdriveFolderId ? elements.backupGdriveFolderId.value.trim() : ''
};
}
async function fetchAdminBackupConfig() {
try {
const res = await fetch('/api/admin/backups/config', {
headers: getAuthHeaders()
});
if (res.status === 401) {
logout();
return;
}
if (!res.ok) throw new Error('Failed to load backup configuration.');
const data = await res.json();
const cfg = data.config || {};
if (elements.backupSchedule) elements.backupSchedule.value = cfg.schedule || 'off';
if (elements.backupScheduleTime) elements.backupScheduleTime.value = cfg.scheduleTime || '02:00';
if (elements.backupRetentionCount) elements.backupRetentionCount.value = cfg.retentionCount || 14;
if (elements.backupIncludeRemoteImages) elements.backupIncludeRemoteImages.checked = Boolean(cfg.includeRemoteImages);
if (elements.backupS3Enabled) elements.backupS3Enabled.checked = Boolean(cfg.s3Enabled);
if (elements.backupS3Endpoint) elements.backupS3Endpoint.value = cfg.s3Endpoint || 'https://s3.wasabisys.com';
if (elements.backupS3Region) elements.backupS3Region.value = cfg.s3Region || 'us-east-1';
if (elements.backupS3Bucket) elements.backupS3Bucket.value = cfg.s3Bucket || '';
if (elements.backupS3Prefix) elements.backupS3Prefix.value = cfg.s3Prefix || 'forkfolio-backups/';
if (elements.backupS3AccessKey) elements.backupS3AccessKey.value = cfg.s3AccessKeyId || '';
if (elements.backupS3SecretKey) elements.backupS3SecretKey.value = cfg.s3SecretAccessKey || '';
if (elements.backupGdriveEnabled) elements.backupGdriveEnabled.checked = Boolean(cfg.gdriveEnabled);
if (elements.backupGdriveClientId) elements.backupGdriveClientId.value = cfg.gdriveClientId || '';
if (elements.backupGdriveClientSecret) elements.backupGdriveClientSecret.value = cfg.gdriveClientSecret || '';
if (elements.backupGdriveRefreshToken) elements.backupGdriveRefreshToken.value = cfg.gdriveRefreshToken || '';
if (elements.backupGdriveFolderId) elements.backupGdriveFolderId.value = cfg.gdriveFolderId || '';
if (elements.backupLastRunBadge) {
if (cfg.lastRunAt) {
const dt = new Date(cfg.lastRunAt).toLocaleString();
const st = cfg.lastRunStatus === 'success' ? '✓ Success' : cfg.lastRunStatus === 'error' ? '⚠ Failed' : cfg.lastRunStatus || '';
elements.backupLastRunBadge.textContent = `Last Cloud Run: ${dt} (${st})`;
} else {
elements.backupLastRunBadge.textContent = 'Last Cloud Run: Never';
}
}
renderAdminBackupHistory(data.history || []);
} catch (err) {
console.error('Error fetching backup config:', err);
showBackupStatus(err.message, 'error');
}
}
function formatBytesHuman(bytes) {
const n = Number(bytes) || 0;
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
}
function renderAdminBackupHistory(history) {
if (!elements.adminBackupsHistoryList) return;
elements.adminBackupsHistoryList.innerHTML = '';
if (!history || history.length === 0) {
elements.adminBackupsHistoryList.innerHTML = `
<tr>
<td colspan="7" style="text-align: center; color: var(--text-muted); padding: 24px; font-style: italic;">
No cloud backups have been recorded yet. Configure Wasabi S3 or Google Drive above and click "Backup to Cloud Now", or use "Download .DB" / "Download .JSON" for immediate local computer downloads.
</td>
</tr>
`;
return;
}
history.forEach(item => {
const tr = document.createElement('tr');
const dateStr = new Date(item.createdAt).toLocaleString();
const triggerBadge = item.triggerType === 'scheduled'
? `<span class="admin-role-badge" style="background:rgba(99,102,241,0.12);color:#6366f1;padding:3px 8px;border-radius:6px;font-size:0.78rem;">Scheduled</span>`
: `<span class="admin-role-badge" style="background:rgba(249,115,22,0.12);color:var(--accent);padding:3px 8px;border-radius:6px;font-size:0.78rem;">Manual</span>`;
const destBadges = [];
if (item.s3KeyDb) {
destBadges.push(`<span class="admin-role-badge" style="background:rgba(16,185,129,0.12);color:#10b981;padding:3px 8px;border-radius:6px;font-size:0.76rem;margin-right:4px;">Wasabi S3</span>`);
}
if (item.gdriveFileIdDb) {
destBadges.push(`<span class="admin-role-badge" style="background:rgba(59,130,246,0.12);color:#3b82f6;padding:3px 8px;border-radius:6px;font-size:0.76rem;">Google Drive</span>`);
}
const statusBadge = item.status === 'success'
? `<span style="color:#10b981;font-weight:600;font-size:0.84rem;">✓ Success</span>`
: `<span style="color:#ef4444;font-weight:600;font-size:0.84rem;" title="${(item.errorMessage || '').replace(/"/g, '&quot;')}">⚠ Error</span>`;
const hasCloudFiles = Boolean(item.s3KeyDb || item.gdriveFileIdDb);
tr.innerHTML = `
<td style="font-size:0.88rem;color:var(--text-main);white-space:nowrap;">${dateStr}</td>
<td>${triggerBadge}</td>
<td style="font-size:0.85rem;color:var(--text-muted);">${item.userCount} users · ${item.recipeCount} recipes · ${item.shoppingListCount} lists</td>
<td style="font-size:0.85rem;color:var(--text-muted);white-space:nowrap;">DB: ${formatBytesHuman(item.dbSizeBytes)}<br>JSON: ${formatBytesHuman(item.jsonSizeBytes)}</td>
<td>${destBadges.join('') || '<span style="color:var(--text-muted);font-size:0.8rem;">None</span>'}</td>
<td>${statusBadge}${item.errorMessage ? `<div style="font-size:0.75rem;color:#ef4444;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${item.errorMessage.replace(/"/g, '&quot;')}">${item.errorMessage}</div>` : ''}</td>
<td style="text-align:right;white-space:nowrap;">
${hasCloudFiles ? `
<button type="button" class="btn btn-outline btn-dl-db" style="padding:5px 9px;font-size:0.76rem;border-radius:6px;margin-right:4px;" title="Stream .db from Cloud to Computer">
<i data-lucide="download" style="width:12px;height:12px;"></i> .DB
</button>
<button type="button" class="btn btn-outline btn-dl-json" style="padding:5px 9px;font-size:0.76rem;border-radius:6px;margin-right:4px;" title="Stream .json from Cloud to Computer">
<i data-lucide="file-json" style="width:12px;height:12px;"></i> .JSON
</button>
<button type="button" class="btn btn-outline btn-restore-cloud" style="padding:5px 9px;font-size:0.76rem;border-radius:6px;margin-right:4px;border-color:rgba(249,115,22,0.4);color:var(--accent);" title="Restore Database from this Cloud Snapshot">
<i data-lucide="rotate-ccw" style="width:12px;height:12px;"></i> Restore
</button>
` : ''}
<button type="button" class="btn btn-danger btn-delete-cloud" style="padding:5px 9px;font-size:0.76rem;border-radius:6px;" title="Delete Snapshot from Cloud & History">
<i data-lucide="trash-2" style="width:12px;height:12px;"></i>
</button>
</td>
`;
const btnDlDb = tr.querySelector('.btn-dl-db');
if (btnDlDb) btnDlDb.addEventListener('click', () => downloadCloudBackupItem(item.id, 'db', item.filenameDb));
const btnDlJson = tr.querySelector('.btn-dl-json');
if (btnDlJson) btnDlJson.addEventListener('click', () => downloadCloudBackupItem(item.id, 'json', item.filenameJson));
const btnRestore = tr.querySelector('.btn-restore-cloud');
if (btnRestore) btnRestore.addEventListener('click', () => restoreCloudBackupItem(item.id, dateStr));
const btnDelete = tr.querySelector('.btn-delete-cloud');
if (btnDelete) btnDelete.addEventListener('click', () => deleteCloudBackupItem(item.id, dateStr));
elements.adminBackupsHistoryList.appendChild(tr);
});
lucide.createIcons();
}
async function handleSaveBackupConfig(e) {
e.preventDefault();
try {
const payload = getBackupFormPayload();
const res = await fetch('/api/admin/backups/config', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to save backup configuration.');
showBackupStatus('Backup & schedule settings saved!', 'success');
fetchAdminBackupConfig();
} catch (err) {
showBackupStatus(err.message, 'error');
}
}
async function handleTestS3Connection() {
const btn = elements.btnBackupTestS3;
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<i data-lucide="loader" style="width:13px;height:13px;"></i> Testing...`;
lucide.createIcons();
try {
const payload = getBackupFormPayload();
const res = await fetch('/api/admin/backups/test-s3', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Wasabi S3 connection test failed.');
showBackupStatus(data.message || 'Wasabi S3 connection verified!', 'success');
} catch (err) {
showBackupStatus(`Wasabi S3 Error: ${err.message}`, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
lucide.createIcons();
}
}
async function handleTestGdriveConnection() {
const btn = elements.btnBackupTestGdrive;
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<i data-lucide="loader" style="width:13px;height:13px;"></i> Testing...`;
lucide.createIcons();
try {
const payload = getBackupFormPayload();
const res = await fetch('/api/admin/backups/test-gdrive', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Google Drive connection test failed.');
if (data.folderId && elements.backupGdriveFolderId && !elements.backupGdriveFolderId.value) {
elements.backupGdriveFolderId.value = data.folderId;
}
showBackupStatus(data.message || 'Google Drive API connection verified!', 'success');
} catch (err) {
showBackupStatus(`Google Drive Error: ${err.message}`, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
lucide.createIcons();
}
}
async function handleAuthorizeGoogleDrive() {
try {
const clientId = elements.backupGdriveClientId ? elements.backupGdriveClientId.value.trim() : '';
const clientSecret = elements.backupGdriveClientSecret ? elements.backupGdriveClientSecret.value.trim() : '';
if (!clientId || !clientSecret) {
showBackupStatus('Please enter your Google OAuth2 Client ID and Client Secret first.', 'error');
return;
}
const res = await fetch('/api/admin/backups/gdrive/auth-url', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
clientId,
clientSecret,
origin: window.location.origin
})
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to generate Google OAuth2 URL.');
window.open(data.authUrl, 'gdrive_oauth', 'width=560,height=680');
} catch (err) {
showBackupStatus(err.message, 'error');
}
}
async function handleRunCloudBackupNow() {
const btn = elements.btnBackupRunCloud;
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<i data-lucide="loader" style="width:15px;height:15px;"></i> Backing up to Cloud...`;
lucide.createIcons();
try {
// First save current form config so any newly toggled provider is persisted
const payload = getBackupFormPayload();
await fetch('/api/admin/backups/config', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(payload)
});
const res = await fetch('/api/admin/backups/run', {
method: 'POST',
headers: getAuthHeaders()
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Cloud backup failed.');
showBackupStatus('Cloud backup completed! Temporary local snapshot was wiped after upload.', 'success');
fetchAdminBackupConfig();
} catch (err) {
showBackupStatus(err.message, 'error');
fetchAdminBackupConfig();
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
lucide.createIcons();
}
}
async function handleDownloadLocalBackup(format) {
const btn = format === 'json' ? elements.btnBackupDownloadJson : elements.btnBackupDownloadDb;
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<i data-lucide="loader" style="width:15px;height:15px;"></i> Preparing...`;
lucide.createIcons();
try {
const res = await fetch(`/api/admin/backups/download-local?format=${encodeURIComponent(format)}`, {
headers: getAuthHeaders()
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || 'Failed to generate local backup download.');
}
const blob = await res.blob();
const disposition = res.headers.get('Content-Disposition') || '';
const match = disposition.match(/filename="?([^"]+)"?/i);
const filename = match ? match[1] : `forkfolio-backup-${new Date().toISOString().replace(/[:.]/g, '-')}.${format === 'json' ? 'json' : 'db'}`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
showBackupStatus(`Downloaded ${filename} to your computer (no copy kept on server).`, 'success');
} catch (err) {
showBackupStatus(err.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
lucide.createIcons();
}
}
async function downloadCloudBackupItem(id, format, fallbackName) {
try {
showBackupStatus(`Streaming ${format.toUpperCase()} snapshot from cloud storage...`, 'info');
const res = await fetch(`/api/admin/backups/${id}/download-cloud?format=${encodeURIComponent(format)}`, {
headers: getAuthHeaders()
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || 'Failed to download backup from cloud.');
}
const blob = await res.blob();
const filename = fallbackName || `forkfolio-cloud-backup-${id}.${format}`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
showBackupStatus(`Downloaded ${filename} from cloud storage.`, 'success');
} catch (err) {
showBackupStatus(err.message, 'error');
}
}
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');
}
}
async function deleteCloudBackupItem(id, dateLabel) {
const confirmed = window.confirm(`Delete the cloud backup from ${dateLabel} from remote storage and history?`);
if (!confirmed) return;
try {
const res = await fetch(`/api/admin/backups/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to delete backup.');
showBackupStatus('Cloud backup deleted.', 'success');
fetchAdminBackupConfig();
} catch (err) {
showBackupStatus(err.message, 'error');
}
}
async function handleRestoreLocalFile(e) {
const file = e.target.files && e.target.files[0];
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');
const base64Data = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = String(reader.result || '');
const commaIdx = result.indexOf(',');
resolve(commaIdx >= 0 ? result.slice(commaIdx + 1) : result);
};
reader.onerror = () => reject(new Error('Failed to read selected file.'));
reader.readAsDataURL(file);
});
const res = await fetch('/api/admin/backups/restore-upload', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
filename: file.name,
dataBase64: base64Data
})
});
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');
}
}

View file

@ -210,13 +210,16 @@
<!-- User Admin Management Console -->
<div id="user-admin-panel" style="display: none;" class="admin-panel-container">
<!-- Admin Console Sub-Tabs -->
<div class="admin-tabs" style="display: flex; gap: 12px; margin-bottom: 24px; border-bottom: 1px solid var(--border-color); padding-bottom: 12px;">
<div class="admin-tabs" style="display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 24px; border-bottom: 1px solid var(--border-color); padding-bottom: 12px;">
<button class="btn btn-primary" id="btn-admin-tab-users" style="padding: 8px 16px; border-radius: 6px; font-size: 0.9rem; display: flex; align-items: center; gap: 6px;">
<i data-lucide="users" style="width: 16px; height: 16px;"></i> <span>Chef Accounts</span>
</button>
<button class="btn btn-outline" id="btn-admin-tab-shares" style="padding: 8px 16px; border-radius: 6px; font-size: 0.9rem; display: flex; align-items: center; gap: 6px;">
<i data-lucide="globe" style="width: 16px; height: 16px;"></i> <span>Public Shared Links</span>
</button>
<button class="btn btn-outline" id="btn-admin-tab-backups" style="padding: 8px 16px; border-radius: 6px; font-size: 0.9rem; display: flex; align-items: center; gap: 6px;">
<i data-lucide="hard-drive" style="width: 16px; height: 16px;"></i> <span>Database Backups</span>
</button>
</div>
<!-- Section 1: Chef Accounts Management -->
@ -272,6 +275,238 @@
</table>
</div>
</div>
<!-- Section 3: Cloud & Manual Database Backups -->
<div id="admin-backups-section" style="display: none;">
<div class="admin-panel-header backup-top-header">
<div>
<h3 style="font-size: 1.4rem; color: var(--text-main); margin-bottom: 4px;">Database Backup & Recovery Console</h3>
<p style="font-size: 0.85rem; color: var(--text-muted);">
Back up users, recipes, embedded dish photos, and shopping lists to Wasabi S3, Google Drive, or directly to your local computer. No backup files are retained on the server disk.
</p>
</div>
<div class="backup-quick-actions">
<button type="button" class="btn btn-primary" id="btn-backup-run-cloud" title="Run immediate cloud backup to enabled providers">
<i data-lucide="cloud-upload"></i> <span>Backup to Cloud Now</span>
</button>
<button type="button" class="btn btn-outline" id="btn-backup-download-db" title="Download SQLite .db snapshot to your computer">
<i data-lucide="download"></i> <span>Download .DB</span>
</button>
<button type="button" class="btn btn-outline" id="btn-backup-download-json" title="Download full JSON archive with embedded images to your computer">
<i data-lucide="file-json"></i> <span>Download .JSON</span>
</button>
<input type="file" id="backup-restore-file-input" accept=".db,.sqlite,.json" style="display: none;">
<button type="button" class="btn btn-outline" id="btn-backup-restore-local" title="Restore database from a .db or .json file on your computer">
<i data-lucide="upload"></i> <span>Restore from File</span>
</button>
</div>
</div>
<div id="backup-status-banner" class="backup-status-alert" style="display: none;"></div>
<form id="admin-backup-config-form" class="backup-config-grid">
<!-- Card 1: Automated Schedule & Archive Options -->
<div class="backup-config-card">
<div class="backup-card-header">
<div class="backup-card-title">
<i data-lucide="calendar-clock"></i>
<h4>Automated Schedule & Format</h4>
</div>
<span id="backup-last-run-badge" class="shopping-stats-pill" style="display: none;">Never run</span>
</div>
<div class="form-group">
<label for="backup-schedule-frequency">Cloud Backup Schedule</label>
<select id="backup-schedule-frequency" class="shopping-select" style="width: 100%;">
<option value="off">Manual Backups Only (Schedule Off)</option>
<option value="6h">Every 6 Hours</option>
<option value="12h">Every 12 Hours</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</div>
<div class="form-grid-2" id="backup-time-row">
<div class="form-group">
<label for="backup-schedule-time">Time of Day (24h)</label>
<input type="time" id="backup-schedule-time" value="02:00" class="shopping-select" style="width: 100%;">
</div>
<div class="form-group" id="backup-day-group">
<label for="backup-schedule-day">Day of Week (Weekly)</label>
<select id="backup-schedule-day" class="shopping-select" style="width: 100%;">
<option value="0">Sunday</option>
<option value="1">Monday</option>
<option value="2">Tuesday</option>
<option value="3">Wednesday</option>
<option value="4">Thursday</option>
<option value="5">Friday</option>
<option value="6">Saturday</option>
</select>
</div>
</div>
<div class="form-grid-2">
<div class="form-group">
<label for="backup-retention-count">Cloud Retention (Keep Last N)</label>
<select id="backup-retention-count" class="shopping-select" style="width: 100%;">
<option value="7">Keep Last 7 Backups</option>
<option value="14" selected>Keep Last 14 Backups</option>
<option value="30">Keep Last 30 Backups</option>
<option value="60">Keep Last 60 Backups</option>
<option value="0">Unlimited</option>
</select>
</div>
<div class="form-group">
<label for="backup-format">Cloud Backup Format</label>
<select id="backup-format" class="shopping-select" style="width: 100%;">
<option value="both">Both (.DB Snapshot + .JSON Archive)</option>
<option value="db">SQLite .DB Snapshot Only</option>
<option value="json">JSON Archive Only</option>
</select>
</div>
</div>
<div class="form-group" style="margin-top: 8px;">
<label class="checkbox-container" style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="backup-embed-images" checked style="cursor: pointer;">
<span class="checkbox-label" style="font-size: 0.88rem; color: var(--text-main);">
Fetch & embed external recipe images into JSON backup archives
</span>
</label>
</div>
</div>
<!-- Card 2: Wasabi / S3 Connection -->
<div class="backup-config-card">
<div class="backup-card-header">
<div class="backup-card-title">
<i data-lucide="database"></i>
<h4>Wasabi / S3 Connection</h4>
</div>
<label class="shopping-consolidate-toggle">
<input type="checkbox" id="backup-s3-enabled">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-label">Enable S3</span>
</label>
</div>
<div class="form-grid-2">
<div class="form-group">
<label for="backup-s3-endpoint">S3 Endpoint URL</label>
<input type="text" id="backup-s3-endpoint" placeholder="https://s3.us-east-1.wasabisys.com">
</div>
<div class="form-group">
<label for="backup-s3-region">Region</label>
<input type="text" id="backup-s3-region" placeholder="us-east-1">
</div>
</div>
<div class="form-grid-2">
<div class="form-group">
<label for="backup-s3-bucket">Bucket Name</label>
<input type="text" id="backup-s3-bucket" placeholder="my-forkfolio-backups">
</div>
<div class="form-group">
<label for="backup-s3-prefix">Folder Prefix</label>
<input type="text" id="backup-s3-prefix" placeholder="forkfolio-backups/">
</div>
</div>
<div class="form-grid-2">
<div class="form-group">
<label for="backup-s3-access-key">Access Key ID</label>
<input type="text" id="backup-s3-access-key" placeholder="Wasabi Access Key" autocomplete="off">
</div>
<div class="form-group">
<label for="backup-s3-secret-key">Secret Access Key</label>
<input type="password" id="backup-s3-secret-key" placeholder="Wasabi Secret Key" autocomplete="off">
</div>
</div>
<div style="display: flex; justify-content: flex-end; margin-top: 6px;">
<button type="button" class="btn btn-outline btn-sm" id="btn-backup-test-s3">
<i data-lucide="plug-zap"></i> <span>Test S3 Connection</span>
</button>
</div>
</div>
<!-- Card 3: Google Drive Connection -->
<div class="backup-config-card">
<div class="backup-card-header">
<div class="backup-card-title">
<i data-lucide="cloud"></i>
<h4>Google Drive API Connection</h4>
</div>
<label class="shopping-consolidate-toggle">
<input type="checkbox" id="backup-gdrive-enabled">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-label">Enable Drive</span>
</label>
</div>
<div class="form-grid-2">
<div class="form-group">
<label for="backup-gdrive-client-id">OAuth2 Client ID</label>
<input type="text" id="backup-gdrive-client-id" placeholder="...apps.googleusercontent.com" autocomplete="off">
</div>
<div class="form-group">
<label for="backup-gdrive-client-secret">OAuth2 Client Secret</label>
<input type="password" id="backup-gdrive-client-secret" placeholder="GOCSPX-..." autocomplete="off">
</div>
</div>
<div class="form-grid-2">
<div class="form-group">
<label for="backup-gdrive-refresh-token">OAuth2 Refresh Token</label>
<input type="password" id="backup-gdrive-refresh-token" placeholder="1//04... (or click Authorize below)" autocomplete="off">
</div>
<div class="form-group">
<label for="backup-gdrive-folder-id">Target Drive Folder ID (Optional)</label>
<input type="text" id="backup-gdrive-folder-id" placeholder="Auto-creates 'ForkFolio Backups' if blank">
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 6px;">
<button type="button" class="btn btn-outline btn-sm" id="btn-backup-authorize-gdrive" title="Open Google OAuth consent screen to connect your Google Drive account">
<i data-lucide="key-round"></i> <span>Authorize with Google</span>
</button>
<button type="button" class="btn btn-outline btn-sm" id="btn-backup-test-gdrive">
<i data-lucide="plug-zap"></i> <span>Test Drive Connection</span>
</button>
</div>
</div>
<div class="backup-save-bar">
<button type="submit" class="btn btn-primary" id="btn-backup-save-config">
<i data-lucide="save"></i> <span>Save Backup Configuration & Schedule</span>
</button>
</div>
</form>
<!-- Cloud Backup History Table -->
<div style="margin-top: 32px;">
<h4 style="font-size: 1.15rem; color: var(--text-main); margin-bottom: 12px;">Cloud Backup History</h4>
<div class="table-responsive">
<table class="admin-users-table">
<thead>
<tr>
<th>Backup Date</th>
<th>Trigger</th>
<th>Contents</th>
<th>Size</th>
<th>Cloud Destinations</th>
<th>Status</th>
<th style="text-align: right;">Actions</th>
</tr>
</thead>
<tbody id="admin-backups-history-list">
<!-- Populated via JS -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</section>
</main>

View file

@ -2827,3 +2827,120 @@ h1, h2, h3, h4, h5, h6 {
flex: 1;
}
}
/* =============================================================
ADMIN BACKUP & RECOVERY CONSOLE STYLES
============================================================= */
.backup-top-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 20px;
}
.backup-quick-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.backup-status-alert {
padding: 12px 16px;
border-radius: var(--border-radius-md);
font-size: 0.9rem;
font-weight: 500;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
border: 1px solid var(--border-color);
}
.backup-status-alert.success {
background: rgba(16, 185, 129, 0.12);
border-color: rgba(16, 185, 129, 0.35);
color: #10b981;
}
.backup-status-alert.error {
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.35);
color: var(--danger);
}
.backup-status-alert.info {
background: rgba(99, 102, 241, 0.12);
border-color: rgba(99, 102, 241, 0.35);
color: #818cf8;
}
.backup-config-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 20px;
align-items: stretch;
}
.backup-config-card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
box-shadow: var(--shadow-sm);
}
.backup-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-color);
margin-bottom: 4px;
}
.backup-card-title {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-main);
}
.backup-card-title i,
.backup-card-title svg {
width: 18px;
height: 18px;
color: var(--primary);
}
.backup-card-title h4 {
font-size: 1.05rem;
font-weight: 700;
margin: 0;
}
.backup-save-bar {
grid-column: 1 / -1;
display: flex;
justify-content: flex-end;
padding-top: 4px;
}
@media (max-width: 768px) {
.backup-config-grid {
grid-template-columns: 1fr;
}
.backup-quick-actions {
width: 100%;
}
.backup-quick-actions button {
flex: 1;
}
}

1218
server.js

File diff suppressed because it is too large Load diff