Implements the user-store + invite-store + admin routes. The whole system is opt-in via siteConfig.authProviders.email.enabled = true; single-user TOTP-only installs see zero behavior change. Backend: - src/security/user-store.js: users + allowlist + bootstrap sentinel, atomic writes, last-admin protection, defensive dataDir resolver. - src/security/invite-store.js: single-use tokens (SHA-256 hashed on disk), TTL, auto-prune, defensive dataDir resolver. - routes/auth/admin.js: /me, /admin/users (CRUD), /admin/allowlist, /admin/invites (CRUD), public /invites/:token (peek + accept). - routes/auth/index.js: wires userStore, gates admin router on email auth being enabled. - src/auth/providers/email.js: verify() enforces allowlist, creates user record, tags req.user; default-enabled flipped to opt-in. - src/auth/providers/totp.js: bootstraps system@totp.local admin on first verify so current DNS2 operator shows in /admin/users. - src/security/audit-logger.js: middleware adds userId/userEmail/ userRole/viaProvider to log details when req.user is tagged. - PUBLIC_ROUTES + CSRF allowlists updated for invite redemption. Frontend: - status/js/admin.js: modal overlay with users list (role-edit, delete), invite form (email/role/TTL), copy-link button, outstanding-invites list with revoke. Exports window.AdminPanel. - status/js/core/init.js: calls AdminPanel.attachTrigger so the Admin button only appears when /me returns isAdmin=true. Tests: 35 new tests across 3 files (user-store, invite-store, auth multistore integration). Full suite: 1298/1298 passing. Docs: BACKLOG.md marks DC-048 done. CHANGELOG.md [Unreleased] section gets the DC-048 entry.
289 lines
10 KiB
JavaScript
289 lines
10 KiB
JavaScript
// ========== DASHBOARD INITIALIZATION ==========
|
|
(function () {
|
|
|
|
function loadCustomServices() {
|
|
const customServices = safeGet('custom-services');
|
|
if (customServices) {
|
|
try {
|
|
const services = JSON.parse(customServices);
|
|
// Merge with default APPS, avoiding duplicates
|
|
services.forEach(service => {
|
|
if (!window.APPS.find(app => app.id === service.id)) {
|
|
window.APPS.push(service);
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.warn('Failed to load custom services:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize custom services immediately so window.APPS is populated before buildGrid runs
|
|
loadCustomServices();
|
|
|
|
// Staggered animation for top cards too
|
|
function animateTopCards() {
|
|
const topCards = document.querySelectorAll('.top .card');
|
|
topCards.forEach((card, index) => {
|
|
card.style.transitionDelay = `${Math.min(index * 60, 300)}ms`;
|
|
});
|
|
requestAnimationFrame(() => {
|
|
topCards.forEach(card => card.classList.add('loaded'));
|
|
});
|
|
}
|
|
|
|
function registerServiceWorker() {
|
|
if (!('serviceWorker' in navigator)) return;
|
|
if (!window.isSecureContext && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1') return;
|
|
|
|
const register = () => {
|
|
navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }).catch((error) => {
|
|
console.warn('[init] Service worker registration failed:', error);
|
|
});
|
|
};
|
|
|
|
if (document.readyState === 'complete') {
|
|
register();
|
|
} else {
|
|
window.addEventListener('load', register, { once: true });
|
|
}
|
|
}
|
|
|
|
// Initialize dashboard (called after TOTP gate check or directly if TOTP disabled)
|
|
// NOTE: loadServices comes from window.loadServices (exported by grid.js)
|
|
let _dashboardInitialized = false;
|
|
async function initializeDashboard() {
|
|
if (_dashboardInitialized) {
|
|
console.warn('[init] initializeDashboard called again, skipping duplicate');
|
|
return;
|
|
}
|
|
_dashboardInitialized = true;
|
|
await window.loadServices();
|
|
await loadTemplateCategories();
|
|
window.buildGrid();
|
|
animateTopCards();
|
|
window.refreshAll();
|
|
setInterval(window.refreshAll, DC.POLL.DASHBOARD);
|
|
if (typeof window.refreshCredsButtons === 'function') window.refreshCredsButtons();
|
|
if (typeof window.refreshMonitoringWidgets === 'function') window.refreshMonitoringWidgets();
|
|
// Update auth card (may have already been updated by the auto-load IIFE but ensure it's correct)
|
|
if (typeof window._updateAuthCard === 'function') {
|
|
try {
|
|
const r = await fetch('/api/v1/totp/config', { cache: 'no-store' });
|
|
const d = await r.json();
|
|
if (d.success) window._updateAuthCard(d.config.enabled && d.config.isSetUp, d.config.sessionDuration);
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
if (window.__dashcaddySiteConfigLoaded) {
|
|
try {
|
|
await window.__dashcaddySiteConfigLoaded;
|
|
} catch (_) { /* ignore */ }
|
|
}
|
|
// Lazy-load onboarding only once per install, otherwise just add the tour button
|
|
addTourButton();
|
|
if (shouldLoadOnboarding()) {
|
|
loadOnboarding();
|
|
}
|
|
|
|
// DC-048: inject the "Admin" trigger button into the top bar. The
|
|
// button only renders when /me returns isAdmin=true; the module
|
|
// re-checks every 60s so a permission downgrade takes effect.
|
|
if (window.AdminPanel && typeof window.AdminPanel.attachTrigger === 'function') {
|
|
try {
|
|
await window.AdminPanel.attachTrigger(document.body);
|
|
} catch (e) {
|
|
console.warn('[init] AdminPanel attachTrigger failed:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Lazy-load onboarding bundle (52 KB) — only loaded when needed
|
|
function loadOnboarding() {
|
|
if (document.querySelector('script[src="/dist/onboarding.js"]')) return; // already loading/loaded
|
|
const s = document.createElement('script');
|
|
s.src = '/dist/onboarding.js';
|
|
s.defer = true;
|
|
document.head.appendChild(s);
|
|
// Also load onboarding CSS if not already present
|
|
if (!document.querySelector('link[href="/css/driver.min.css"]')) {
|
|
const link = document.createElement('link');
|
|
link.rel = 'stylesheet';
|
|
link.href = '/css/driver.min.css';
|
|
document.head.appendChild(link);
|
|
}
|
|
if (!document.querySelector('link[href="/css/onboarding.css"]')) {
|
|
const link = document.createElement('link');
|
|
link.rel = 'stylesheet';
|
|
link.href = '/css/onboarding.css';
|
|
document.head.appendChild(link);
|
|
}
|
|
}
|
|
|
|
// Check if onboarding should auto-start (first-time user)
|
|
function shouldLoadOnboarding() {
|
|
if (typeof SITE !== 'undefined' && SITE.onboardingCompleted) {
|
|
return false;
|
|
}
|
|
try {
|
|
const data = JSON.parse(localStorage.getItem('dashcaddy_onboarding'));
|
|
return !data || (!data.tourCompleted && data.currentStep === 0);
|
|
} catch (_) {
|
|
return true; // No data means first-time user
|
|
}
|
|
}
|
|
|
|
// ===== Collapsible toolbar sections =====
|
|
function initToolbarSections() {
|
|
const sections = document.querySelectorAll('.tools-section');
|
|
if (!sections.length) return;
|
|
|
|
// Restore saved state from localStorage
|
|
let saved = {};
|
|
try { saved = JSON.parse(localStorage.getItem('toolbar-sections') || '{}'); } catch (_) {}
|
|
|
|
sections.forEach(section => {
|
|
const key = section.dataset.section;
|
|
const header = section.querySelector('.tools-section-header');
|
|
if (!header) return;
|
|
|
|
// Restore state (default: collapsed)
|
|
if (saved[key]) {
|
|
section.classList.add('open');
|
|
header.setAttribute('aria-expanded', 'true');
|
|
}
|
|
|
|
header.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
const isOpen = section.classList.toggle('open');
|
|
header.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
|
|
|
// Save state
|
|
const state = {};
|
|
document.querySelectorAll('.tools-section').forEach(s => {
|
|
state[s.dataset.section] = s.classList.contains('open');
|
|
});
|
|
localStorage.setItem('toolbar-sections', JSON.stringify(state));
|
|
});
|
|
});
|
|
}
|
|
|
|
// Initialize toolbar sections on DOM ready
|
|
initToolbarSections();
|
|
|
|
// Add restart tour button (loads bundle on click if not loaded)
|
|
// Visible in primary toolbar until tour completed once, then moves to Admin section
|
|
function addTourButton() {
|
|
if (document.getElementById('restart-tour-btn')) return;
|
|
|
|
let tourDone = typeof SITE !== 'undefined' && SITE.onboardingCompleted;
|
|
try {
|
|
const data = JSON.parse(localStorage.getItem('dashcaddy_onboarding'));
|
|
tourDone = tourDone || !!(data && data.tourCompleted);
|
|
} catch (_) {}
|
|
|
|
// Before first completion: show in primary toolbar. After: tuck into Admin section.
|
|
const target = tourDone
|
|
? document.querySelector('.tools-section[data-section="admin"] .tools-section-items')
|
|
: document.querySelector('.tools-primary');
|
|
if (!target) return;
|
|
|
|
const button = document.createElement('button');
|
|
button.id = 'restart-tour-btn';
|
|
button.textContent = tourDone ? 'Help Tour' : '🎓 Help Tour';
|
|
button.title = 'Restart the onboarding tour';
|
|
button.onclick = () => {
|
|
if (window.DashCaddyOnboarding) {
|
|
window.DashCaddyOnboarding.restartTour();
|
|
} else {
|
|
loadOnboarding();
|
|
// Wait for bundle to load, then start
|
|
const check = setInterval(() => {
|
|
if (window.DashCaddyOnboarding) {
|
|
clearInterval(check);
|
|
window.DashCaddyOnboarding.restartTour();
|
|
}
|
|
}, 100);
|
|
setTimeout(() => clearInterval(check), 5000); // give up after 5s
|
|
}
|
|
};
|
|
target.appendChild(button);
|
|
}
|
|
|
|
window.initializeDashboard = initializeDashboard;
|
|
window.loadCustomServices = loadCustomServices;
|
|
registerServiceWorker();
|
|
|
|
// ===== TEMPLATE CATEGORIES =====
|
|
// Cached template categories from /api/v1/templates for use across the UI
|
|
// (service create/edit, filter dropdown, category badges, etc.)
|
|
async function loadTemplateCategories() {
|
|
try {
|
|
const r = await fetch('/api/v1/templates', { cache: 'no-store' });
|
|
if (!r.ok) return;
|
|
const data = await r.json();
|
|
if (data && data.categories) {
|
|
window.DC_CATEGORIES = data.categories;
|
|
// Also expose via globals.js constant for convenience
|
|
if (typeof DC !== 'undefined') DC.CATEGORIES = data.categories;
|
|
// Populate any category <select> that's already in the DOM
|
|
populateCategorySelects();
|
|
}
|
|
} catch (e) {
|
|
console.warn('[init] Failed to load template categories:', e);
|
|
}
|
|
}
|
|
|
|
function populateCategorySelects() {
|
|
const cats = window.DC_CATEGORIES || (typeof DC !== 'undefined' && DC.CATEGORIES);
|
|
if (!cats) return;
|
|
document.querySelectorAll('select[data-role="service-category"]').forEach(select => {
|
|
const current = select.dataset.current || '';
|
|
// Clear options but keep the first (placeholder)
|
|
const placeholder = select.querySelector('option[value=""]');
|
|
select.innerHTML = '';
|
|
if (placeholder) select.appendChild(placeholder);
|
|
else {
|
|
const ph = document.createElement('option');
|
|
ph.value = '';
|
|
ph.textContent = '— Select category —';
|
|
select.appendChild(ph);
|
|
}
|
|
Object.entries(cats).forEach(([name, info]) => {
|
|
const opt = document.createElement('option');
|
|
opt.value = name;
|
|
opt.textContent = `${info.icon || ''} ${name}`.trim();
|
|
if (name === current) opt.selected = true;
|
|
select.appendChild(opt);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Allow other modules to re-run population after they (re)inject selects
|
|
window.populateCategorySelects = populateCategorySelects;
|
|
window.loadTemplateCategories = loadTemplateCategories;
|
|
|
|
// TOTP-gated initialization
|
|
(async () => {
|
|
try {
|
|
const totpRes = await fetch('/api/v1/totp/config', { cache: 'no-store' });
|
|
const totpData = await totpRes.json();
|
|
|
|
if (totpData.success && totpData.config.enabled) {
|
|
// TOTP is enabled - check if we have a valid session
|
|
const testRes = await fetch('/api/v1/totp/check-session', { cache: 'no-store' });
|
|
if (testRes.status === 401) {
|
|
// Need TOTP verification - show overlay
|
|
window._showTotpOverlay();
|
|
return; // initializeDashboard() will be called after successful verification
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('TOTP check failed, proceeding normally:', e);
|
|
}
|
|
|
|
// TOTP disabled or session valid - initialize immediately
|
|
initializeDashboard();
|
|
})();
|
|
|
|
})();
|