feat: implement admin public shared links dashboard with visitor tracking and revocation override

This commit is contained in:
Chris 2026-05-30 00:32:11 -04:00
parent 0895a97588
commit 7043f1afe1
3 changed files with 246 additions and 22 deletions

View file

@ -45,6 +45,11 @@ const elements = {
userAdminPanel: document.getElementById('user-admin-panel'), userAdminPanel: document.getElementById('user-admin-panel'),
adminUsersList: document.getElementById('admin-users-list'), adminUsersList: document.getElementById('admin-users-list'),
btnAdminAddUser: document.getElementById('btn-admin-add-user'), 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 // Admin Create User Modal
adminCreateUserModal: document.getElementById('admin-create-user-modal'), adminCreateUserModal: document.getElementById('admin-create-user-modal'),
@ -195,6 +200,10 @@ function registerEvents() {
elements.adminCreateUserForm.addEventListener('submit', handleAdminCreateUser); elements.adminCreateUserForm.addEventListener('submit', handleAdminCreateUser);
elements.btnAdminConfirmDelete.addEventListener('click', handleAdminDeleteUser); elements.btnAdminConfirmDelete.addEventListener('click', handleAdminDeleteUser);
// Admin Sub-Tab Switchers
elements.btnAdminTabUsers.addEventListener('click', () => switchAdminSubTab('users'));
elements.btnAdminTabShares.addEventListener('click', () => switchAdminSubTab('shares'));
// Servings Scaler Buttons // Servings Scaler Buttons
elements.scalerMinus.addEventListener('click', () => adjustMultiplier(-0.5)); elements.scalerMinus.addEventListener('click', () => adjustMultiplier(-0.5));
elements.scalerPlus.addEventListener('click', () => adjustMultiplier(0.5)); elements.scalerPlus.addEventListener('click', () => adjustMultiplier(0.5));
@ -1321,7 +1330,7 @@ function switchTab(tabName) {
elements.userAdminPanel.style.display = 'block'; elements.userAdminPanel.style.display = 'block';
if (filters) filters.style.display = 'none'; if (filters) filters.style.display = 'none';
activeTab = 'user-admin'; activeTab = 'user-admin';
fetchAdminUsers(); switchAdminSubTab('users');
} }
lucide.createIcons(); lucide.createIcons();
@ -1399,6 +1408,144 @@ function renderAdminUsers(users) {
lucide.createIcons(); 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 = `
<td colspan="5" style="text-align: center; color: var(--text-muted); padding: 24px; font-style: italic;">
No recipes are currently shared publicly.
</td>
`;
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 = `
<td style="font-weight: 600; color: var(--text-main);">${recipe.title}</td>
<td style="font-weight: 500; color: var(--text-main);">@${recipe.createdBy}</td>
<td style="color: var(--text-muted); font-size: 0.9rem;">${dateStr}</td>
<td>
<span class="admin-role-badge" style="background: rgba(99, 102, 241, 0.12); border: 1px dashed rgba(99, 102, 241, 0.3); color: #6366f1; padding: 4px 8px; border-radius: 6px; display: inline-flex; align-items: center; gap: 4px;">
<i data-lucide="eye" style="width: 12px; height: 12px;"></i>
<span>${recipe.visitorCount} Unique ${recipe.visitorCount === 1 ? 'Visitor' : 'Visitors'}</span>
</span>
</td>
<td style="text-align: right;">
<button class="btn btn-outline" id="${copyActionId}" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px; margin-right: 8px;">
<i data-lucide="copy" style="width: 12px; height: 12px;"></i> Copy Link
</button>
<button class="btn btn-danger" id="${revokeActionId}" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px;">
<i data-lucide="link-2-off" style="width: 12px; height: 12px;"></i> Revoke
</button>
</td>
`;
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) { async function handleAdminCreateUser(e) {
e.preventDefault(); e.preventDefault();
elements.adminCreateError.style.display = 'none'; elements.adminCreateError.style.display = 'none';

View file

@ -121,30 +121,68 @@
<!-- User Admin Management Console --> <!-- User Admin Management Console -->
<div id="user-admin-panel" style="display: none;" class="admin-panel-container"> <div id="user-admin-panel" style="display: none;" class="admin-panel-container">
<div class="admin-panel-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;"> <!-- Admin Console Sub-Tabs -->
<div> <div class="admin-tabs" style="display: flex; gap: 12px; margin-bottom: 24px; border-bottom: 1px solid var(--border-color); padding-bottom: 12px;">
<h3 style="font-size: 1.4rem; color: var(--text-main); margin-bottom: 4px;">Chef Profiles Management</h3> <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;">
<p style="font-size: 0.85rem; color: var(--text-muted);">Create new user profiles and delete accounts with custom recipe preservation options.</p> <i data-lucide="users" style="width: 16px; height: 16px;"></i> <span>Chef Accounts</span>
</div> </button>
<button class="btn btn-primary" id="btn-admin-add-user"> <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="user-plus"></i> <span>Create Chef Account</span> <i data-lucide="globe" style="width: 16px; height: 16px;"></i> <span>Public Shared Links</span>
</button> </button>
</div> </div>
<div class="table-responsive"> <!-- Section 1: Chef Accounts Management -->
<table class="admin-users-table"> <div id="admin-users-section">
<thead> <div class="admin-panel-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
<tr> <div>
<th>Username</th> <h3 style="font-size: 1.4rem; color: var(--text-main); margin-bottom: 4px;">Chef Profiles Management</h3>
<th>Account Type</th> <p style="font-size: 0.85rem; color: var(--text-muted);">Create new user profiles and delete accounts with custom recipe preservation options.</p>
<th>Created Date</th> </div>
<th style="text-align: right;">Actions</th> <button class="btn btn-primary" id="btn-admin-add-user">
</tr> <i data-lucide="user-plus"></i> <span>Create Chef Account</span>
</thead> </button>
<tbody id="admin-users-list"> </div>
<!-- Dynamic list loaded via JS -->
</tbody> <div class="table-responsive">
</table> <table class="admin-users-table">
<thead>
<tr>
<th>Username</th>
<th>Account Type</th>
<th>Created Date</th>
<th style="text-align: right;">Actions</th>
</tr>
</thead>
<tbody id="admin-users-list">
<!-- Dynamic list loaded via JS -->
</tbody>
</table>
</div>
</div>
<!-- Section 2: Shared Recipes & Visitor Statistics -->
<div id="admin-shares-section" style="display: none;">
<div class="admin-panel-header" style="margin-bottom: 24px;">
<h3 style="font-size: 1.4rem; color: var(--text-main); margin-bottom: 4px;">Public Shared Links</h3>
<p style="font-size: 0.85rem; color: var(--text-muted);">Monitor publicly active recipe shares, track dynamic visitor analytics, and manage access overrides.</p>
</div>
<div class="table-responsive">
<table class="admin-users-table">
<thead>
<tr>
<th>Recipe Title</th>
<th>Shared By</th>
<th>Shared Date</th>
<th>Unique Visitors</th>
<th style="text-align: right;">Actions</th>
</tr>
</thead>
<tbody id="admin-shares-list">
<!-- Dynamic list loaded via JS -->
</tbody>
</table>
</div>
</div> </div>
</div> </div>
</section> </section>

View file

@ -597,6 +597,45 @@ app.put('/api/admin/users/:id/recipe-manager', requireAuth, (req, res) => {
} }
}); });
// Admin API: List all public recipe shares and visitor stats (Requires Admin session)
app.get('/api/admin/shared-recipes', requireAuth, (req, res) => {
if (!req.user.isAdmin) {
return res.status(403).json({ error: 'Access denied. Administrator privileges required.' });
}
try {
const rows = db.prepare(`
SELECT id, title, createdBy, shareToken, createdAt
FROM recipes
WHERE isPublic = 1 AND shareToken IS NOT NULL
ORDER BY createdAt DESC
`).all();
const sharedRecipes = rows.map(r => {
let visitorCount = 0;
try {
const stats = db.prepare('SELECT COUNT(*) as count FROM recipe_shares_visits WHERE shareToken = ?').get(r.shareToken);
visitorCount = stats ? stats.count : 0;
} catch (err) {
console.error('Failed to query shared recipe visitor count:', err);
}
return {
id: r.id,
title: r.title,
createdBy: r.createdBy,
shareToken: r.shareToken,
createdAt: r.createdAt,
visitorCount
};
});
res.json(sharedRecipes);
} catch (error) {
console.error('Error fetching admin shared recipes:', error);
res.status(500).json({ error: 'Failed to retrieve shared recipes.' });
}
});
// API: List authenticated user's private recipes // API: List authenticated user's private recipes
app.get('/api/recipes', requireAuth, (req, res) => { app.get('/api/recipes', requireAuth, (req, res) => {
try { try {