ultimate-jekyll-manager
Version:
Ultimate Jekyll dependency manager
530 lines (453 loc) • 17.6 kB
JavaScript
/**
* Admin Users Index Page JavaScript
*/
// Libraries
import { FormManager } from '__main_assets__/js/libs/form-manager.js';
import authorizedFetch from '__main_assets__/js/libs/authorized-fetch.js';
import { formatTimeAgo, capitalize, setStatValue, setStatSubValue } from '__main_assets__/js/libs/admin-helpers.js';
import { getPrerenderedIcon } from '__main_assets__/js/libs/prerendered-icons.js';
import webManager from 'web-manager';
// State
let formManager = null;
let editFormManager = null;
let editingUid = null;
let searchResults = [];
const SEARCH_LIMIT = 50;
// Module
export default () => {
return new Promise(async function (resolve) {
await webManager.dom().ready();
webManager.auth().listen({ once: true }, async (state) => {
if (!state.user) {
return;
}
initForm();
loadStatCards();
});
return resolve();
});
};
// Initialize FormManager for search
function initForm() {
formManager = new FormManager('#user-search-form', {
allowResubmit: true,
submittingText: 'Searching...',
});
formManager.on('submit', async ({ data }) => {
const term = data?.search?.query?.trim();
if (!term) {
return;
}
showLoading();
await searchUsers(term);
});
}
// Load stat card counts
async function loadStatCards() {
const { collection, query, where, getCountFromServer } = await import('firebase/firestore');
const db = webManager.firebaseFirestore;
const now = Math.floor(Date.now() / 1000);
const thirtyDaysAgo = now - (30 * 24 * 60 * 60);
const [totalUsers, newUsers, activeSubs, activeUsers] = await Promise.allSettled([
getCountFromServer(collection(db, 'users')),
getCountFromServer(query(collection(db, 'users'), where('metadata.created.timestampUNIX', '>=', thirtyDaysAgo))),
getCountFromServer(query(collection(db, 'users'), where('subscription.status', '==', 'active'), where('subscription.product.id', '!=', 'basic'))),
getCountFromServer(query(collection(db, 'users'), where('metadata.updated.timestampUNIX', '>=', thirtyDaysAgo))),
]);
setStatValue('stat-total-users', totalUsers);
setStatSubValue('stat-new-users', newUsers, 'in 30d');
setStatValue('stat-active-subs', activeSubs);
setStatValue('stat-active-users', activeUsers);
}
// Search users by email prefix or UID prefix
// Uses Firestore >= / \uf8ff range trick for prefix matching on both
async function searchUsers(term) {
const firestore = webManager.firestore();
const results = new Map();
const prefixEnd = term + '\uf8ff';
// Run email prefix search and UID prefix search in parallel
await Promise.allSettled([
// 1) Email prefix match
firestore.collection('users')
.where('auth.email', '>=', term)
.where('auth.email', '<=', prefixEnd)
.limit(SEARCH_LIMIT)
.get()
.then((snapshot) => {
snapshot.docs.forEach((doc) => {
results.set(doc.id, { id: doc.id, ...doc.data() });
});
}),
// 2) UID prefix match
firestore.collection('users')
.where('__name__', '>=', term)
.where('__name__', '<=', prefixEnd)
.limit(SEARCH_LIMIT)
.get()
.then((snapshot) => {
snapshot.docs.forEach((doc) => {
results.set(doc.id, { id: doc.id, ...doc.data() });
});
}),
]);
searchResults = Array.from(results.values());
if (searchResults.length === 0) {
showEmpty('No users match your search');
return;
}
renderUsers();
}
// Render users table
function renderUsers() {
const $prompt = document.getElementById('users-prompt');
const $loading = document.getElementById('users-loading');
const $empty = document.getElementById('users-empty');
const $table = document.getElementById('users-table');
const $tbody = document.getElementById('users-tbody');
const $footer = document.getElementById('users-footer');
const $count = document.getElementById('users-count');
if ($loading) $loading.classList.add('d-none');
if ($prompt) $prompt.classList.add('d-none');
if ($empty) $empty.classList.add('d-none');
if ($table) $table.classList.remove('d-none');
if ($footer) $footer.classList.remove('d-none');
if ($tbody) $tbody.innerHTML = '';
searchResults.forEach((user) => {
const email = user?.auth?.email || 'Unknown';
const uid = user.id;
const resolved = webManager.auth().resolveSubscription(user);
const plan = resolved.plan;
const isPaid = plan !== 'basic';
const expiresUNIX = user?.subscription?.expires?.timestampUNIX;
const updatedUNIX = user?.metadata?.updated?.timestampUNIX;
let expiresText = '—';
if (expiresUNIX) {
const now = Math.floor(Date.now() / 1000);
if (expiresUNIX < now) {
expiresText = 'Expired';
} else {
expiresText = new Date(expiresUNIX * 1000).toLocaleDateString();
}
}
const updatedText = updatedUNIX ? formatTimeAgo(updatedUNIX * 1000) : '—';
const badgeClass = isPaid ? 'bg-success text-white' : 'bg-body-tertiary text-body';
const $row = document.createElement('tr');
$row.innerHTML = `
<td>
<div class="d-flex align-items-center">
${getPrerenderedIcon('user', 'fa-sm me-2 text-muted')}
<div>
<div class="text-truncate" style="max-width: 220px;">${webManager.utilities().escapeHTML(email)}</div>
<div class="font-monospace text-muted text-truncate" style="max-width: 220px; font-size: 0.7rem;">${webManager.utilities().escapeHTML(uid)}</div>
</div>
</div>
</td>
<td><span class="badge ${badgeClass}">${webManager.utilities().escapeHTML(capitalize(plan))}</span></td>
<td class="small ${expiresText === 'Expired' ? 'text-danger' : 'text-muted'}">${expiresText}</td>
<td class="text-muted small">${updatedText}</td>
<td>
<div class="dropdown">
<button class="btn btn-sm btn-adaptive rounded-circle" type="button" data-bs-toggle="dropdown">
${getPrerenderedIcon('ellipsis-vertical', 'fa-sm')}
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item small btn-view-user" href="#">
${getPrerenderedIcon('eye', 'fa-sm me-2')}
View details
</a></li>
<li><a class="dropdown-item small btn-edit-user" href="#">
${getPrerenderedIcon('pen', 'fa-sm me-2')}
Edit user
</a></li>
<li><a class="dropdown-item small btn-copy-uid" href="#">
${getPrerenderedIcon('copy', 'fa-sm me-2')}
Copy UID
</a></li>
<li><a class="dropdown-item small btn-view-firebase" href="#">
${getPrerenderedIcon('fire', 'fa-sm me-2')}
View in Explorer
</a></li>
<li><a class="dropdown-item small btn-signin-as" href="#">
${getPrerenderedIcon('right-to-bracket', 'fa-sm me-2')}
Sign in as user
</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item small text-danger btn-delete-user" href="#">
${getPrerenderedIcon('trash', 'fa-sm me-2')}
Delete user
</a></li>
</ul>
</div>
</td>
`;
// Wire up action buttons
$row.querySelector('.btn-view-user').addEventListener('click', (e) => {
e.preventDefault();
viewUser(uid, user);
});
$row.querySelector('.btn-edit-user').addEventListener('click', (e) => {
e.preventDefault();
editUser(uid, user);
});
$row.querySelector('.btn-copy-uid').addEventListener('click', (e) => {
e.preventDefault();
navigator.clipboard.writeText(uid);
});
$row.querySelector('.btn-view-firebase').addEventListener('click', (e) => {
e.preventDefault();
window.location.href = `/admin/firebase?collection=users&doc=${uid}`;
});
$row.querySelector('.btn-signin-as').addEventListener('click', (e) => {
e.preventDefault();
signInAsUser(uid, email);
});
$row.querySelector('.btn-delete-user').addEventListener('click', (e) => {
e.preventDefault();
deleteUser(uid, email);
});
$tbody.appendChild($row);
});
if ($count) {
$count.textContent = `${searchResults.length} result${searchResults.length !== 1 ? 's' : ''}`;
}
}
// ============================================
// User Actions
// ============================================
function viewUser(uid, userData) {
const $label = document.getElementById('user-detail-modal-label');
const $json = document.getElementById('user-detail-json');
if ($label) {
$label.textContent = `${userData?.auth?.email || uid}`;
}
if ($json) {
$json.textContent = JSON.stringify(userData, null, 2);
}
// Wire modal footer buttons
const $copyBtn = document.getElementById('btn-copy-uid');
if ($copyBtn) {
$copyBtn.onclick = () => navigator.clipboard.writeText(uid);
}
const $firebaseBtn = document.getElementById('btn-view-in-firebase');
if ($firebaseBtn) {
$firebaseBtn.onclick = () => {
window.location.href = `/admin/firebase?collection=users&doc=${uid}`;
};
}
// Show modal
const modal = new bootstrap.Modal(document.getElementById('user-detail-modal'));
modal.show();
}
async function signInAsUser(uid, email) {
openSignInAsModalLoading(email);
try {
const response = await authorizedFetch(`${webManager.getApiUrl()}/backend-manager/user/token`, {
method: 'POST',
timeout: 30000,
response: 'json',
tries: 1,
log: true,
body: { uid: uid },
});
const token = response?.token;
if (!token) {
throw new Error('No token returned from server');
}
const signinUrl = new URL('/signin', window.location.origin);
signinUrl.searchParams.set('authSignout', 'true');
signinUrl.searchParams.set('authCustomToken', token);
signinUrl.searchParams.set('authReturnUrl', '/dashboard');
showSignInAsModalReady(email, signinUrl.toString());
} catch (error) {
console.error('Failed to create sign-in link:', error);
showSignInAsModalError(error.message || 'Unknown error');
}
}
function openSignInAsModalLoading(email) {
const $loading = document.getElementById('signin-as-loading');
const $ready = document.getElementById('signin-as-ready');
const $error = document.getElementById('signin-as-error');
const $loadingEmail = document.getElementById('signin-as-loading-email');
const $navigateBtn = document.getElementById('btn-signin-as-navigate');
if ($loading) $loading.classList.remove('d-none');
if ($ready) $ready.classList.add('d-none');
if ($error) $error.classList.add('d-none');
if ($navigateBtn) $navigateBtn.classList.add('d-none');
if ($loadingEmail) $loadingEmail.textContent = email;
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('signin-as-modal'));
modal.show();
}
function showSignInAsModalReady(email, urlString) {
const $loading = document.getElementById('signin-as-loading');
const $ready = document.getElementById('signin-as-ready');
const $error = document.getElementById('signin-as-error');
const $email = document.getElementById('signin-as-email');
const $url = document.getElementById('signin-as-url');
const $copyBtn = document.getElementById('btn-signin-as-copy');
const $navigateBtn = document.getElementById('btn-signin-as-navigate');
if ($loading) $loading.classList.add('d-none');
if ($error) $error.classList.add('d-none');
if ($ready) $ready.classList.remove('d-none');
if ($navigateBtn) $navigateBtn.classList.remove('d-none');
if ($email) $email.textContent = email;
if ($url) $url.value = urlString;
if ($copyBtn) {
$copyBtn.onclick = async () => {
await navigator.clipboard.writeText(urlString).catch(() => {});
const originalHTML = $copyBtn.innerHTML;
$copyBtn.innerHTML = `${getPrerenderedIcon('circle-check', 'fa-sm')}`;
$copyBtn.classList.add('btn-success');
$copyBtn.classList.remove('btn-outline-adaptive');
setTimeout(() => {
$copyBtn.innerHTML = originalHTML;
$copyBtn.classList.remove('btn-success');
$copyBtn.classList.add('btn-outline-adaptive');
}, 1500);
};
}
if ($navigateBtn) {
$navigateBtn.onclick = () => {
window.open(urlString, '_blank', 'noopener');
};
}
}
function showSignInAsModalError(message) {
const $loading = document.getElementById('signin-as-loading');
const $ready = document.getElementById('signin-as-ready');
const $error = document.getElementById('signin-as-error');
const $errorMessage = document.getElementById('signin-as-error-message');
const $navigateBtn = document.getElementById('btn-signin-as-navigate');
if ($loading) $loading.classList.add('d-none');
if ($ready) $ready.classList.add('d-none');
if ($error) $error.classList.remove('d-none');
if ($navigateBtn) $navigateBtn.classList.add('d-none');
if ($errorMessage) $errorMessage.textContent = message;
}
async function deleteUser(uid, email) {
if (!confirm(`Delete user ${email} (${uid})?\n\nThis will permanently delete their account and cannot be undone.`)) {
return;
}
try {
await authorizedFetch(`${webManager.getApiUrl()}/backend-manager/user`, {
method: 'DELETE',
timeout: 30000,
response: 'json',
tries: 1,
log: true,
body: { uid: uid },
});
// Remove from results and re-render
searchResults = searchResults.filter((u) => u.id !== uid);
if (searchResults.length === 0) {
showEmpty('No users match your search');
} else {
renderUsers();
}
} catch (error) {
console.error('Failed to delete user:', error);
alert(`Failed to delete user: ${error.message || 'Unknown error'}`);
}
}
function editUser(uid, userData) {
editingUid = uid;
// Populate read-only fields
const $uid = document.getElementById('edit-uid');
const $email = document.getElementById('edit-email');
if ($uid) $uid.value = uid;
if ($email) $email.value = userData?.auth?.email || '';
// Populate editable fields
const $admin = document.getElementById('edit-role-admin');
if ($admin) $admin.checked = !!userData?.roles?.admin;
const $plan = document.getElementById('edit-plan');
if ($plan) $plan.value = userData?.subscription?.product?.id || 'basic';
const $expires = document.getElementById('edit-expires');
if ($expires) {
const expiresUNIX = userData?.subscription?.expires?.timestampUNIX;
if (expiresUNIX) {
$expires.value = new Date(expiresUNIX * 1000).toISOString().split('T')[0];
} else {
$expires.value = '';
}
}
// Init FormManager on first use
if (!editFormManager) {
initEditForm();
} else {
editFormManager.reset();
}
const modal = new bootstrap.Modal(document.getElementById('user-edit-modal'));
modal.show();
}
function initEditForm() {
editFormManager = new FormManager('#user-edit-form', {
allowResubmit: true,
submittingText: 'Saving...',
});
editFormManager.on('submit', async ({ data }) => {
if (!editingUid) {
return;
}
const firestore = webManager.firestore();
// Build the update document
const update = {
roles: {
admin: !!data?.roles?.admin,
},
subscription: {
product: {
id: data?.subscription?.product?.id?.trim() || 'basic',
},
},
};
// Handle expiry date
const expiresDate = data?.subscription?.expires?.date;
if (expiresDate) {
const expiresTimestamp = Math.floor(new Date(expiresDate + 'T23:59:59').getTime() / 1000);
update.subscription.expires = {
timestamp: new Date(expiresTimestamp * 1000).toISOString(),
timestampUNIX: expiresTimestamp,
};
}
await firestore.doc(`users/${editingUid}`).set(update, { merge: true });
// Update local search results cache
const idx = searchResults.findIndex((u) => u.id === editingUid);
if (idx !== -1) {
// Deep merge the update into cached user
const user = searchResults[idx];
user.roles = { ...user.roles, ...update.roles };
user.subscription = user.subscription || {};
user.subscription.product = { ...user.subscription.product, ...update.subscription.product };
if (update.subscription.expires) {
user.subscription.expires = { ...user.subscription.expires, ...update.subscription.expires };
}
}
// Close modal and re-render
bootstrap.Modal.getInstance(document.getElementById('user-edit-modal'))?.hide();
renderUsers();
editFormManager.showSuccess('User updated');
});
}
// UI state helpers
function showLoading() {
hideAll();
const $loading = document.getElementById('users-loading');
if ($loading) $loading.classList.remove('d-none');
}
function showEmpty(message) {
hideAll();
const $empty = document.getElementById('users-empty');
if ($empty) {
$empty.classList.remove('d-none');
$empty.textContent = message || 'No users found';
}
}
function hideAll() {
['users-loading', 'users-empty', 'users-prompt'].forEach((id) => {
const $el = document.getElementById(id);
if ($el) $el.classList.add('d-none');
});
const $table = document.getElementById('users-table');
const $footer = document.getElementById('users-footer');
if ($table) $table.classList.add('d-none');
if ($footer) $footer.classList.add('d-none');
}