From 7043f1afe1918a0134236d9e975622c33ac82dbb Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 30 May 2026 00:32:11 -0400 Subject: [PATCH] feat: implement admin public shared links dashboard with visitor tracking and revocation override --- public/app.js | 149 +++++++++++++++++++++++++++++++++++++++++++++- public/index.html | 80 ++++++++++++++++++------- server.js | 39 ++++++++++++ 3 files changed, 246 insertions(+), 22 deletions(-) diff --git a/public/app.js b/public/app.js index 22c8c88..59dfdc7 100644 --- a/public/app.js +++ b/public/app.js @@ -45,6 +45,11 @@ const elements = { userAdminPanel: document.getElementById('user-admin-panel'), adminUsersList: document.getElementById('admin-users-list'), btnAdminAddUser: document.getElementById('btn-admin-add-user'), + btnAdminTabUsers: document.getElementById('btn-admin-tab-users'), + btnAdminTabShares: document.getElementById('btn-admin-tab-shares'), + adminUsersSection: document.getElementById('admin-users-section'), + adminSharesSection: document.getElementById('admin-shares-section'), + adminSharesList: document.getElementById('admin-shares-list'), // Admin Create User Modal adminCreateUserModal: document.getElementById('admin-create-user-modal'), @@ -195,6 +200,10 @@ function registerEvents() { elements.adminCreateUserForm.addEventListener('submit', handleAdminCreateUser); elements.btnAdminConfirmDelete.addEventListener('click', handleAdminDeleteUser); + // Admin Sub-Tab Switchers + elements.btnAdminTabUsers.addEventListener('click', () => switchAdminSubTab('users')); + elements.btnAdminTabShares.addEventListener('click', () => switchAdminSubTab('shares')); + // Servings Scaler Buttons elements.scalerMinus.addEventListener('click', () => adjustMultiplier(-0.5)); elements.scalerPlus.addEventListener('click', () => adjustMultiplier(0.5)); @@ -1321,7 +1330,7 @@ function switchTab(tabName) { elements.userAdminPanel.style.display = 'block'; if (filters) filters.style.display = 'none'; activeTab = 'user-admin'; - fetchAdminUsers(); + switchAdminSubTab('users'); } lucide.createIcons(); @@ -1399,6 +1408,144 @@ function renderAdminUsers(users) { lucide.createIcons(); } +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'); + + elements.adminUsersSection.style.display = 'block'; + elements.adminSharesSection.style.display = 'none'; + 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(); + } + lucide.createIcons(); +} + +async function fetchAdminSharedRecipes() { + try { + const response = await fetch('/api/admin/shared-recipes', { + headers: getAuthHeaders() + }); + + if (response.status === 401) { + logout(); + return; + } + + if (!response.ok) throw new Error('Failed to load shared recipe records.'); + + const recipes = await response.json(); + renderAdminSharedRecipes(recipes); + } catch (error) { + console.error('Error fetching admin shared recipes:', error); + } +} + +function renderAdminSharedRecipes(recipes) { + elements.adminSharesList.innerHTML = ''; + + if (recipes.length === 0) { + const tr = document.createElement('tr'); + tr.innerHTML = ` + + No recipes are currently shared publicly. + + `; + elements.adminSharesList.appendChild(tr); + return; + } + + recipes.forEach(recipe => { + const tr = document.createElement('tr'); + + const dateStr = new Date(recipe.createdAt).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + + const shareLink = `${window.location.origin}/share.html?token=${recipe.shareToken}`; + + const copyActionId = `btn-copy-share-${recipe.id}`; + const revokeActionId = `btn-revoke-share-${recipe.id}`; + + tr.innerHTML = ` + ${recipe.title} + @${recipe.createdBy} + ${dateStr} + + + + ${recipe.visitorCount} Unique ${recipe.visitorCount === 1 ? 'Visitor' : 'Visitors'} + + + + + + + `; + + elements.adminSharesList.appendChild(tr); + + // Bind click events inside the row + document.getElementById(copyActionId).addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(shareLink); + alert(`Public share link successfully copied to your clipboard!\n\n${shareLink}`); + } catch (clipErr) { + prompt('Public Share Link:', shareLink); + } + }); + + document.getElementById(revokeActionId).addEventListener('click', () => { + revokeSharedRecipeFromList(recipe.id, recipe.title); + }); + }); + + lucide.createIcons(); +} + +async function revokeSharedRecipeFromList(recipeId, recipeTitle) { + if (!confirm(`Are you sure you want to revoke the public share link for "${recipeTitle}" as an Administrator?\n\nThis will make the recipe private, disable the public link immediately, and clear all visitor history.`)) return; + + try { + const response = await fetch(`/api/recipes/${recipeId}/revoke-share`, { + method: 'POST', + headers: getAuthHeaders() + }); + + if (response.status === 401) { + logout(); + return; + } + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.error || 'Failed to revoke shared link.'); + } + + alert(`Successfully revoked the public share link for "${recipeTitle}".`); + fetchAdminSharedRecipes(); + } catch (error) { + console.error('Error revoking recipe share from admin console:', error); + alert(`Error: ${error.message}`); + } +} + async function handleAdminCreateUser(e) { e.preventDefault(); elements.adminCreateError.style.display = 'none'; diff --git a/public/index.html b/public/index.html index 2869e89..12c1978 100644 --- a/public/index.html +++ b/public/index.html @@ -121,30 +121,68 @@