diff --git a/public/app.js b/public/app.js index bc1388c..4613b52 100644 --- a/public/app.js +++ b/public/app.js @@ -81,11 +81,17 @@ const elements = { 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'), + gdriveStatusDot: document.getElementById('gdrive-status-dot'), + gdriveStatusTitle: document.getElementById('gdrive-status-title'), + gdriveStatusSubtitle: document.getElementById('gdrive-status-subtitle'), + btnAuthorizeGdriveLabel: document.getElementById('btn-authorize-gdrive-label'), btnBackupAuthorizeGdrive: document.getElementById('btn-backup-authorize-gdrive'), + btnBackupDisconnectGdrive: document.getElementById('btn-backup-disconnect-gdrive'), + backupGdriveFolderSelect: document.getElementById('backup-gdrive-folder-select'), + btnGdriveNewFolder: document.getElementById('btn-gdrive-new-folder'), + btnGdriveRefreshFolders: document.getElementById('btn-gdrive-refresh-folders'), + gdriveFolderHint: document.getElementById('gdrive-folder-hint'), + gdriveInlineStatus: document.getElementById('gdrive-inline-status'), btnBackupTestGdrive: document.getElementById('btn-backup-test-gdrive'), adminBackupsHistoryList: document.getElementById('admin-backups-history-list'), @@ -309,11 +315,24 @@ function registerEvents() { elements.btnBackupTestS3.addEventListener('click', handleTestS3Connection); elements.btnBackupTestGdrive.addEventListener('click', handleTestGdriveConnection); elements.btnBackupAuthorizeGdrive.addEventListener('click', handleAuthorizeGoogleDrive); + if (elements.btnBackupDisconnectGdrive) { + elements.btnBackupDisconnectGdrive.addEventListener('click', handleDisconnectGoogleDrive); + } + if (elements.backupGdriveFolderSelect) { + elements.backupGdriveFolderSelect.addEventListener('change', handleSelectGdriveFolder); + } + if (elements.btnGdriveNewFolder) { + elements.btnGdriveNewFolder.addEventListener('click', handleCreateGdriveFolder); + } + if (elements.btnGdriveRefreshFolders) { + elements.btnGdriveRefreshFolders.addEventListener('click', () => loadGoogleDriveFolders(true)); + } } window.addEventListener('message', (event) => { if (event.data && event.data.type === 'GDRIVE_OAUTH_SUCCESS') { - showBackupStatus('Google Drive account authorized and Refresh Token saved!', 'success'); + showGdriveInlineStatus('Google Drive connected! Select your backup destination folder below.', 'success'); + showBackupStatus('Google Drive account connected!', 'success'); fetchAdminBackupConfig(); } }); @@ -2988,45 +3007,257 @@ function renderShoppingListsWorkspace() { // ========================================== let backupStatusTimeout = null; +let gdriveInlineTimeout = null; +let gdriveAuthPollTimer = null; function showBackupStatus(message, type = 'info') { - if (!elements.backupStatusAlert) return; + const banner = elements.backupStatusBanner; + if (!banner) return; if (backupStatusTimeout) clearTimeout(backupStatusTimeout); - elements.backupStatusAlert.className = `backup-status-alert status-${type}`; + banner.className = `backup-status-alert status-${type}`; const icon = type === 'success' ? 'check-circle-2' : type === 'error' ? 'alert-triangle' : 'info'; - elements.backupStatusAlert.innerHTML = `${message}`; - elements.backupStatusAlert.classList.remove('hidden'); + banner.innerHTML = `${message}`; + banner.style.display = 'flex'; lucide.createIcons(); if (type !== 'error') { backupStatusTimeout = setTimeout(() => { - elements.backupStatusAlert.classList.add('hidden'); + banner.style.display = 'none'; }, 8000); } } +function showGdriveInlineStatus(message, type = 'info') { + const box = elements.gdriveInlineStatus; + if (!box) return; + if (gdriveInlineTimeout) clearTimeout(gdriveInlineTimeout); + + 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; + + if (type !== 'error') { + gdriveInlineTimeout = setTimeout(() => { + box.classList.add('hidden'); + }, 7000); + } +} + function getBackupFormPayload() { + const selectedOpt = elements.backupGdriveFolderSelect && elements.backupGdriveFolderSelect.selectedOptions[0]; return { - schedule: elements.backupSchedule ? elements.backupSchedule.value : 'off', + scheduleFrequency: elements.backupScheduleFrequency ? elements.backupScheduleFrequency.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, + scheduleDay: elements.backupScheduleDay ? Number(elements.backupScheduleDay.value) || 0 : 0, + retentionCount: elements.backupRetentionCount ? Number(elements.backupRetentionCount.value) : 14, + backupFormat: elements.backupFormat ? elements.backupFormat.value : 'both', + embedRemoteImages: elements.backupEmbedImages ? elements.backupEmbedImages.checked : true, s3Enabled: elements.backupS3Enabled ? elements.backupS3Enabled.checked : false, - s3Endpoint: elements.backupS3Endpoint ? elements.backupS3Endpoint.value.trim() : 'https://s3.wasabisys.com', + s3Endpoint: elements.backupS3Endpoint ? elements.backupS3Endpoint.value.trim() : 'https://s3.us-east-1.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() : '' + gdriveFolderId: elements.backupGdriveFolderSelect && !elements.backupGdriveFolderSelect.disabled ? elements.backupGdriveFolderSelect.value : undefined, + gdriveFolderName: selectedOpt && !elements.backupGdriveFolderSelect.disabled ? selectedOpt.dataset.folderName || selectedOpt.textContent : undefined }; } +function renderGdriveConnectionState(cfg) { + const isConnected = Boolean(cfg.gdriveConnected); + + if (elements.gdriveStatusDot) { + elements.gdriveStatusDot.style.background = isConnected ? '#10b981' : 'var(--text-muted)'; + elements.gdriveStatusDot.style.boxShadow = isConnected ? '0 0 8px rgba(16, 185, 129, 0.6)' : 'none'; + } + if (elements.gdriveStatusTitle) { + elements.gdriveStatusTitle.textContent = isConnected + ? `Connected to Google Drive${cfg.gdriveAccountEmail ? ` (${cfg.gdriveAccountEmail})` : ''}` + : 'Not Connected to Google Drive'; + elements.gdriveStatusTitle.style.color = isConnected ? '#10b981' : 'var(--text-main)'; + } + if (elements.gdriveStatusSubtitle) { + elements.gdriveStatusSubtitle.textContent = isConnected + ? `Destination folder: "${cfg.gdriveFolderName || 'ForkFolio Backups'}"` + : 'Click Authorize with Google to link your account in one click.'; + } + if (elements.btnAuthorizeGdriveLabel) { + elements.btnAuthorizeGdriveLabel.textContent = isConnected ? 'Switch Account' : 'Authorize with Google'; + } + if (elements.btnBackupAuthorizeGdrive) { + if (isConnected) { + elements.btnBackupAuthorizeGdrive.classList.remove('btn-primary'); + elements.btnBackupAuthorizeGdrive.classList.add('btn-outline'); + } else { + elements.btnBackupAuthorizeGdrive.classList.add('btn-primary'); + elements.btnBackupAuthorizeGdrive.classList.remove('btn-outline'); + } + } + if (elements.btnBackupDisconnectGdrive) { + elements.btnBackupDisconnectGdrive.classList.toggle('hidden', !isConnected); + } + if (elements.backupGdriveFolderSelect) { + elements.backupGdriveFolderSelect.disabled = !isConnected; + if (!isConnected) { + elements.backupGdriveFolderSelect.innerHTML = ``; + } + } + if (elements.btnGdriveNewFolder) { + elements.btnGdriveNewFolder.disabled = !isConnected; + } + if (elements.btnGdriveRefreshFolders) { + elements.btnGdriveRefreshFolders.disabled = !isConnected; + } + + if (isConnected) { + loadGoogleDriveFolders(false, cfg.gdriveFolderId, cfg.gdriveFolderName); + } +} + +async function loadGoogleDriveFolders(showFeedback = false, fallbackId = '', fallbackName = 'ForkFolio Backups') { + if (!elements.backupGdriveFolderSelect) return; + try { + if (showFeedback) { + showGdriveInlineStatus('Loading folders from your Google Drive...', 'info'); + } + const res = await fetch('/api/admin/backups/gdrive/folders', { + headers: getAuthHeaders() + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to load Google Drive folders.'); + + const folders = Array.isArray(data.folders) ? data.folders : []; + const activeId = data.currentFolderId || fallbackId; + const activeName = data.currentFolderName || fallbackName || 'ForkFolio Backups'; + + if (data.accountEmail && elements.gdriveStatusTitle) { + elements.gdriveStatusTitle.textContent = `Connected to Google Drive (${data.accountEmail})`; + } + + elements.backupGdriveFolderSelect.innerHTML = ''; + + // Ensure the active folder is present in the list + const hasActive = folders.some(f => f.id === activeId); + if (activeId && !hasActive) { + folders.unshift({ id: activeId, name: activeName }); + } + + folders.forEach(folder => { + const opt = document.createElement('option'); + opt.value = folder.id; + opt.dataset.folderName = folder.name; + opt.textContent = `📁 ${folder.name}`; + if (folder.id === activeId) { + opt.selected = true; + } + elements.backupGdriveFolderSelect.appendChild(opt); + }); + + if (elements.gdriveStatusSubtitle) { + const selOpt = elements.backupGdriveFolderSelect.selectedOptions[0]; + const dispName = selOpt ? (selOpt.dataset.folderName || activeName) : activeName; + elements.gdriveStatusSubtitle.textContent = `Destination folder: "${dispName}"`; + } + + if (showFeedback) { + showGdriveInlineStatus(`Loaded ${folders.length} folder(s) from Google Drive.`, 'success'); + } + } catch (err) { + console.warn('Error loading Google Drive folders:', err); + if (showFeedback) { + showGdriveInlineStatus(err.message, 'error'); + } + } +} + +async function handleSelectGdriveFolder() { + if (!elements.backupGdriveFolderSelect) return; + const folderId = elements.backupGdriveFolderSelect.value; + const selectedOpt = elements.backupGdriveFolderSelect.selectedOptions[0]; + const folderName = selectedOpt ? (selectedOpt.dataset.folderName || selectedOpt.textContent.replace(/^📁\s*/, '')) : 'ForkFolio Backups'; + if (!folderId) return; + + try { + const res = await fetch('/api/admin/backups/gdrive/select-folder', { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify({ folderId, folderName }) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to save selected folder.'); + if (elements.backupGdriveEnabled) elements.backupGdriveEnabled.checked = true; + if (elements.gdriveStatusSubtitle) { + elements.gdriveStatusSubtitle.textContent = `Destination folder: "${folderName}"`; + } + showGdriveInlineStatus(`✓ Backups will now be saved to "${folderName}" in Google Drive.`, 'success'); + } catch (err) { + showGdriveInlineStatus(err.message, 'error'); + } +} + +async function handleCreateGdriveFolder() { + const folderName = window.prompt('Enter a name for the new Google Drive backup folder:', 'ForkFolio Backups'); + if (!folderName || !folderName.trim()) return; + + const btn = elements.btnGdriveNewFolder; + const origHtml = btn.innerHTML; + btn.disabled = true; + + try { + showGdriveInlineStatus(`Creating folder "${folderName.trim()}" in Google Drive...`, 'info'); + const res = await fetch('/api/admin/backups/gdrive/folders', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ name: folderName.trim() }) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to create folder.'); + + await loadGoogleDriveFolders(false, data.folder.id, data.folder.name); + if (elements.backupGdriveEnabled) elements.backupGdriveEnabled.checked = true; + showGdriveInlineStatus(`✓ Created and selected folder "${data.folder.name}"!`, 'success'); + } catch (err) { + showGdriveInlineStatus(err.message, 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = origHtml; + lucide.createIcons(); + } +} + +async function handleDisconnectGoogleDrive() { + const confirmed = window.confirm('Disconnect your Google Drive account from ForkFolio?'); + if (!confirmed) return; + + try { + const res = await fetch('/api/admin/backups/gdrive/disconnect', { + method: 'POST', + headers: getAuthHeaders() + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to disconnect Google Drive.'); + showGdriveInlineStatus('Google Drive account disconnected.', 'info'); + fetchAdminBackupConfig(); + } catch (err) { + showGdriveInlineStatus(err.message, 'error'); + } +} + async function fetchAdminBackupConfig() { try { const res = await fetch('/api/admin/backups/config', { @@ -3040,13 +3271,15 @@ async function fetchAdminBackupConfig() { const data = await res.json(); const cfg = data.config || {}; - if (elements.backupSchedule) elements.backupSchedule.value = cfg.schedule || 'off'; + if (elements.backupScheduleFrequency) elements.backupScheduleFrequency.value = cfg.scheduleFrequency || 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.backupScheduleDay) elements.backupScheduleDay.value = String(cfg.scheduleDay !== undefined ? cfg.scheduleDay : 0); + if (elements.backupRetentionCount) elements.backupRetentionCount.value = String(cfg.retentionCount !== undefined ? cfg.retentionCount : 14); + if (elements.backupFormat) elements.backupFormat.value = cfg.backupFormat || 'both'; + if (elements.backupEmbedImages) elements.backupEmbedImages.checked = Boolean(cfg.embedRemoteImages); if (elements.backupS3Enabled) elements.backupS3Enabled.checked = Boolean(cfg.s3Enabled); - if (elements.backupS3Endpoint) elements.backupS3Endpoint.value = cfg.s3Endpoint || 'https://s3.wasabisys.com'; + if (elements.backupS3Endpoint) elements.backupS3Endpoint.value = cfg.s3Endpoint || 'https://s3.us-east-1.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/'; @@ -3054,18 +3287,17 @@ async function fetchAdminBackupConfig() { 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 || ''; + + renderGdriveConnectionState(cfg); if (elements.backupLastRunBadge) { + elements.backupLastRunBadge.style.display = 'inline-flex'; 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'; + elements.backupLastRunBadge.textContent = 'Never run'; } } @@ -3222,11 +3454,10 @@ async function handleTestGdriveConnection() { }); 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'); + showGdriveInlineStatus(data.message || 'Google Drive connection verified!', 'success'); + showBackupStatus(data.message || 'Google Drive connection verified!', 'success'); } catch (err) { + showGdriveInlineStatus(`Google Drive Error: ${err.message}`, 'error'); showBackupStatus(`Google Drive Error: ${err.message}`, 'error'); } finally { btn.disabled = false; @@ -3236,26 +3467,57 @@ async function handleTestGdriveConnection() { } async function handleAuthorizeGoogleDrive() { + // Open popup synchronously inside user click gesture so browsers never block it + const popup = window.open('about:blank', 'gdrive_oauth', 'width=560,height=680'); + if (popup) { + popup.document.write('
Your Google account is now linked to ForkFolio. You can now select your backup folder in the admin panel.
+This window will close automatically...
+${error}
`); + } + if (!code) { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + return res.end('ForkFolio Google Drive OAuth Receiver'); + } + await completeGoogleDriveOAuthCode(code, `http://127.0.0.1:${GDRIVE_LOOPBACK_PORT}/`); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(renderOAuthSuccessHtml()); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(`${err.message}
`); + } + }); + srv.on('error', () => { + resolve(null); + }); + srv.listen(GDRIVE_LOOPBACK_PORT, '127.0.0.1', () => { + gdriveLoopbackServer = srv; + resolve(`http://127.0.0.1:${GDRIVE_LOOPBACK_PORT}/`); + }); + } catch (e) { + resolve(null); + } + }); +} + +// Generate Google Drive OAuth2 authorization URL for one-click admin connection (zero user input required) +app.post('/api/admin/backups/gdrive/auth-url', requireAuth, async (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Administrator privileges required.' }); } - const { clientId, clientSecret, origin } = req.body || {}; - if (!clientId || !clientSecret) { - return res.status(400).json({ error: 'Please enter your Google OAuth Client ID and Client Secret first.' }); + try { + const cfg = getBackupConfig(); + const customClientId = (req.body?.clientId || cfg.gdriveClientId || '').trim(); + const customClientSecret = (req.body?.clientSecret || cfg.gdriveClientSecret || '').trim(); + + if (customClientId && customClientSecret) { + db.prepare('UPDATE backup_config SET gdriveClientId = ?, gdriveClientSecret = ? WHERE id = 1').run( + customClientId, + customClientSecret + ); + } + + const effectiveClientId = customClientId || DEFAULT_GDRIVE_CLIENT_ID; + let redirectUri = await ensureGdriveLoopbackServer(); + if (!redirectUri || customClientId) { + const origin = req.body?.origin || `http://localhost:${PORT}`; + redirectUri = `${origin}/api/admin/backups/gdrive/callback`; + } + + const authParams = new URLSearchParams({ + client_id: effectiveClientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'https://www.googleapis.com/auth/drive', + access_type: 'offline', + prompt: 'consent' + }); + + res.json({ + authUrl: `https://accounts.google.com/o/oauth2/v2/auth?${authParams.toString()}`, + redirectUri + }); + } catch (err) { + res.status(500).json({ error: err.message }); } - - db.prepare('UPDATE backup_config SET gdriveClientId = ?, gdriveClientSecret = ? WHERE id = 1').run( - clientId.trim(), - clientSecret.trim() - ); - - const redirectUri = `${origin || `http://localhost:${PORT}`}/api/admin/backups/gdrive/callback`; - const authParams = new URLSearchParams({ - client_id: clientId.trim(), - redirect_uri: redirectUri, - response_type: 'code', - scope: 'https://www.googleapis.com/auth/drive.file', - access_type: 'offline', - prompt: 'consent' - }); - - res.json({ - authUrl: `https://accounts.google.com/o/oauth2/v2/auth?${authParams.toString()}`, - redirectUri - }); }); -// Google OAuth2 callback to automatically capture and save Refresh Token +// Fallback Google OAuth2 callback on main Express server app.get('/api/admin/backups/gdrive/callback', async (req, res) => { const { code, error } = req.query; if (error || !code) { return res.status(400).send(`${error || 'No authorization code returned.'}
`); } - try { - const cfg = getBackupConfig(); const redirectUri = `${req.protocol}://${req.get('host')}/api/admin/backups/gdrive/callback`; - const tokenParams = new URLSearchParams({ - code: String(code), - client_id: cfg.gdriveClientId, - client_secret: cfg.gdriveClientSecret, - redirect_uri: redirectUri, - grant_type: 'authorization_code' - }); - - const tokenRes = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: tokenParams.toString() - }); - const tokenData = await tokenRes.json().catch(() => ({})); - if (!tokenRes.ok || (!tokenData.refresh_token && !tokenData.access_token)) { - throw new Error(tokenData.error_description || tokenData.error || 'Failed to exchange authorization code.'); - } - - if (tokenData.refresh_token) { - db.prepare('UPDATE backup_config SET gdriveRefreshToken = ?, gdriveEnabled = 1 WHERE id = 1').run(tokenData.refresh_token); - } - - res.send(` - - -Your refresh token has been saved to ForkFolio. You can close this window.
- - - - `); + await completeGoogleDriveOAuthCode(code, redirectUri); + res.send(renderOAuthSuccessHtml()); } catch (err) { res.status(500).send(`${err.message}
`); } }); +// List Google Drive folders so the admin can select where backups should go +app.get('/api/admin/backups/gdrive/folders', requireAuth, async (req, res) => { + if (!req.user.isAdmin) { + return res.status(403).json({ error: 'Administrator privileges required.' }); + } + try { + const cfg = getBackupConfig(); + const accessToken = await getGoogleDriveAccessToken(cfg); + if (!cfg.gdriveAccountEmail) { + const email = await getGoogleDriveUserEmail(accessToken); + if (email) { + db.prepare('UPDATE backup_config SET gdriveAccountEmail = ? WHERE id = 1').run(email); + } + } + const currentFolderId = await ensureGoogleDriveFolder(accessToken, cfg); + const folders = await gdriveListFolders(accessToken); + const updatedCfg = getBackupConfig(); + + res.json({ + folders, + currentFolderId, + currentFolderName: updatedCfg.gdriveFolderName || 'ForkFolio Backups', + accountEmail: updatedCfg.gdriveAccountEmail || '' + }); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +// Create a new folder in Google Drive and select it as the backup destination +app.post('/api/admin/backups/gdrive/folders', requireAuth, async (req, res) => { + if (!req.user.isAdmin) { + return res.status(403).json({ error: 'Administrator privileges required.' }); + } + try { + const { name } = req.body || {}; + if (!name || !name.trim()) { + return res.status(400).json({ error: 'Folder name is required.' }); + } + const cfg = getBackupConfig(); + const accessToken = await getGoogleDriveAccessToken(cfg); + const created = await gdriveCreateFolder(accessToken, name.trim()); + db.prepare('UPDATE backup_config SET gdriveFolderId = ?, gdriveFolderName = ?, gdriveEnabled = 1 WHERE id = 1').run( + created.id, + created.name + ); + res.json({ success: true, folder: created, config: getBackupConfig() }); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +// Immediately save the selected Google Drive destination folder +app.put('/api/admin/backups/gdrive/select-folder', requireAuth, (req, res) => { + if (!req.user.isAdmin) { + return res.status(403).json({ error: 'Administrator privileges required.' }); + } + try { + const { folderId, folderName } = req.body || {}; + if (!folderId) { + return res.status(400).json({ error: 'Folder ID is required.' }); + } + db.prepare('UPDATE backup_config SET gdriveFolderId = ?, gdriveFolderName = ?, gdriveEnabled = 1 WHERE id = 1').run( + folderId.trim(), + (folderName || 'ForkFolio Backups').trim() + ); + res.json({ success: true, config: getBackupConfig() }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Disconnect Google Drive account +app.post('/api/admin/backups/gdrive/disconnect', requireAuth, (req, res) => { + if (!req.user.isAdmin) { + return res.status(403).json({ error: 'Administrator privileges required.' }); + } + try { + db.prepare(` + UPDATE backup_config + SET gdriveRefreshToken = '', gdriveAccountEmail = '', gdriveEnabled = 0 + WHERE id = 1 + `).run(); + res.json({ success: true, config: getBackupConfig() }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // Trigger immediate manual cloud backup (uploads to S3 / Google Drive, no local files kept) app.post('/api/admin/backups/run', requireAuth, async (req, res) => { if (!req.user.isAdmin) { @@ -3269,12 +3553,13 @@ app.post('/api/admin/backups/restore-upload', requireAuth, (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Administrator privileges required.' }); } - const { filename, base64Data } = req.body || {}; - if (!base64Data) { + const { filename, base64Data, dataBase64 } = req.body || {}; + const payload = base64Data || dataBase64; + if (!payload) { return res.status(400).json({ error: 'Backup file payload is required.' }); } try { - const fileBuffer = Buffer.from(base64Data, 'base64'); + const fileBuffer = Buffer.from(payload, 'base64'); const counts = restoreDatabaseFromBuffer(fileBuffer, filename || ''); res.json({ success: true, restored: counts }); } catch (error) {