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'),
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 = `
<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) {
e.preventDefault();
elements.adminCreateError.style.display = 'none';

View file

@ -121,30 +121,68 @@
<!-- User Admin Management Console -->
<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;">
<div>
<h3 style="font-size: 1.4rem; color: var(--text-main); margin-bottom: 4px;">Chef Profiles Management</h3>
<p style="font-size: 0.85rem; color: var(--text-muted);">Create new user profiles and delete accounts with custom recipe preservation options.</p>
</div>
<button class="btn btn-primary" id="btn-admin-add-user">
<i data-lucide="user-plus"></i> <span>Create Chef Account</span>
<!-- 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;">
<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>
</div>
<div class="table-responsive">
<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>
<!-- Section 1: Chef Accounts Management -->
<div id="admin-users-section">
<div class="admin-panel-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
<div>
<h3 style="font-size: 1.4rem; color: var(--text-main); margin-bottom: 4px;">Chef Profiles Management</h3>
<p style="font-size: 0.85rem; color: var(--text-muted);">Create new user profiles and delete accounts with custom recipe preservation options.</p>
</div>
<button class="btn btn-primary" id="btn-admin-add-user">
<i data-lucide="user-plus"></i> <span>Create Chef Account</span>
</button>
</div>
<div class="table-responsive">
<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>
</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
app.get('/api/recipes', requireAuth, (req, res) => {
try {