mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-01 23:24:11 -05:00
feat: implement admin public shared links dashboard with visitor tracking and revocation override
This commit is contained in:
parent
0895a97588
commit
7043f1afe1
3 changed files with 246 additions and 22 deletions
149
public/app.js
149
public/app.js
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,18 @@
|
||||||
|
|
||||||
<!-- 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">
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- 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 class="admin-panel-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
|
||||||
<div>
|
<div>
|
||||||
<h3 style="font-size: 1.4rem; color: var(--text-main); margin-bottom: 4px;">Chef Profiles Management</h3>
|
<h3 style="font-size: 1.4rem; color: var(--text-main); margin-bottom: 4px;">Chef Profiles Management</h3>
|
||||||
|
|
@ -147,6 +159,32 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
|
||||||
39
server.js
39
server.js
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue