When only TOTP is enabled (today's production state for everyone), auth-gate.js was falling through to the legacy TOTP overlay with no visible path to the email provider. The email method was unreachable from the UI even when configured. Fixed: append a small 'Or sign in with email instead ->' link to the bottom of the TOTP card. Clicking swaps the body to the email challenge form. Why this matters even for the single-totp path: email is the phone-friendly, no-app-required recovery path. Operator forgets their TOTP secret at 2am, they can request a link without touching the authenticator app. The link just wasn't reachable before. Renders the link only when the methods response includes both totp and email — preserves the truly-single-provider case unchanged.
277 lines
12 KiB
JavaScript
277 lines
12 KiB
JavaScript
// ===== PLUGGABLE AUTH GATE (DC-049) =====
|
|
//
|
|
// On Caddy redirect to ?auth=required, this module queries
|
|
// GET /api/v1/auth/login/methods to discover which AuthProviders are enabled.
|
|
// If only one provider is enabled, jump straight to its challenge UI
|
|
// (TOTP-only installations today). If multiple providers exist, render a
|
|
// selector first so the user picks how to sign in.
|
|
//
|
|
// Today:
|
|
// - totp: challenge is 6-digit code via the existing TOTP overlay
|
|
// - email: challenge is email address → POST /api/v1/auth/login/email/initiate
|
|
// → server emails magic link (or logs it to console in dev) →
|
|
// waiting for verification (no auto-promotion; user reloads via
|
|
// email link, which routes through /api/v1/auth/login/email/verify)
|
|
//
|
|
// This module wires into the existing totp-auth.js submitTotpCode() flow so
|
|
// the legacy TOTP happy-path is unchanged: when methods returns only
|
|
// `totp`, this module just shows the TOTP overlay and exits.
|
|
|
|
(function() {
|
|
// ---- DOM refs (created lazily below; existing TOTP markup supplies them) ----
|
|
let methodsCache = null;
|
|
|
|
async function fetchMethods() {
|
|
if (methodsCache) return methodsCache;
|
|
try {
|
|
const res = await fetch('/api/v1/auth/login/methods', { cache: 'no-store' });
|
|
if (!res.ok) throw new Error(`methods HTTP ${res.status}`);
|
|
const data = await res.json();
|
|
methodsCache = Array.isArray(data.providers) ? data.providers : [];
|
|
return methodsCache;
|
|
} catch (e) {
|
|
// Public endpoint — if it fails, fall through to legacy TOTP path.
|
|
console.warn('[auth-gate] methods fetch failed; falling back to TOTP-only', e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function showProviderSelector(providers) {
|
|
const overlay = document.getElementById('totp-overlay');
|
|
if (!overlay) return;
|
|
const card = overlay.querySelector('.totp-card');
|
|
if (!card) return;
|
|
|
|
// Save the existing TOTP-challenge body so we can restore it on cancel.
|
|
const originalBody = card.innerHTML;
|
|
if (!card.dataset.originalBody) card.dataset.originalBody = originalBody;
|
|
|
|
const providerButtons = providers.map(p => {
|
|
const label = (p.config && (p.config.label || p.name)) || p.name;
|
|
const btn = `<button class="provider-btn" data-provider="${p.name}"
|
|
style="margin: 6px 4px; padding: 10px 16px; background: var(--accent); color: white; border: 0; border-radius: 6px; cursor: pointer; font-size: 0.95rem;">
|
|
Sign in with ${label}
|
|
</button>`;
|
|
return btn;
|
|
}).join('\n');
|
|
|
|
card.innerHTML = `
|
|
<img class="totp-logo totp-logo-dark" src="/assets/dashcaddy-logo-dark.png" alt="DashCaddy" onerror="this.style.display='none'">
|
|
<img class="totp-logo totp-logo-light" src="/assets/dashcaddy-logo-light.png" alt="DashCaddy" onerror="this.style.display='none'">
|
|
<p class="subtitle">Choose how to sign in</p>
|
|
<div id="auth-gate-providers" style="margin: 14px 0 10px;">${providerButtons}</div>
|
|
<div id="auth-gate-message" style="margin-top: 8px; color: var(--muted); font-size: 0.8rem;"></div>
|
|
`;
|
|
|
|
overlay.classList.add('show');
|
|
|
|
// Wire each button
|
|
card.querySelectorAll('.provider-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const name = btn.dataset.provider;
|
|
const provider = providers.find(p => p.name === name);
|
|
renderProviderChallenge(provider);
|
|
});
|
|
});
|
|
}
|
|
|
|
function restoreOriginalBody() {
|
|
const overlay = document.getElementById('totp-overlay');
|
|
if (!overlay) return;
|
|
const card = overlay.querySelector('.totp-card');
|
|
if (!card || !card.dataset.originalBody) return;
|
|
card.innerHTML = card.dataset.originalBody;
|
|
// Re-bind the original digit input handlers by re-running the totp-auth
|
|
// bootstrap. The simplest path: reload the page, which re-runs all IIFEs.
|
|
window.location.reload();
|
|
}
|
|
|
|
function renderProviderChallenge(provider) {
|
|
const overlay = document.getElementById('totp-overlay');
|
|
if (!overlay) return;
|
|
const card = overlay.querySelector('.totp-card');
|
|
if (!card) return;
|
|
|
|
if (provider.name === 'totp') {
|
|
// Restore the original TOTP markup → existing totp-auth.js submitTotpCode
|
|
// path handles the rest. (Reload is the cleanest path because
|
|
// totp-auth.js wires its input handlers at top-level IIFE time.)
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
|
|
if (provider.name === 'email') {
|
|
card.innerHTML = `
|
|
<img class="totp-logo totp-logo-dark" src="/assets/dashcaddy-logo-dark.png" alt="DashCaddy" onerror="this.style.display='none'">
|
|
<img class="totp-logo totp-logo-light" src="/assets/dashcaddy-logo-light.png" alt="DashCaddy" onerror="this.style.display='none'">
|
|
<p class="subtitle">Sign in with email</p>
|
|
<p style="margin: 6px 0 14px; font-size: 0.85rem; color: var(--muted);">
|
|
We'll email you a one-time sign-in link.
|
|
</p>
|
|
<input id="auth-gate-email-input" type="email" inputmode="email" autocomplete="email"
|
|
placeholder="you@example.com"
|
|
style="width: 100%; padding: 10px 12px; font-size: 1rem; background: var(--input-bg); color: var(--text); border: 1px solid var(--border); border-radius: 6px; box-sizing: border-box;">
|
|
<button id="auth-gate-email-submit" class="provider-btn"
|
|
style="margin-top: 14px; padding: 10px 16px; background: var(--accent); color: white; border: 0; border-radius: 6px; cursor: pointer; font-size: 0.95rem; width: 100%;">
|
|
Send sign-in link
|
|
</button>
|
|
<div id="auth-gate-email-status" style="margin-top: 12px; font-size: 0.85rem; line-height: 1.4;"></div>
|
|
<div style="margin-top: 16px;">
|
|
<a href="#" id="auth-gate-back" style="color: var(--muted); font-size: 0.8rem;">← Back</a>
|
|
</div>
|
|
`;
|
|
overlay.classList.add('show');
|
|
const input = card.querySelector('#auth-gate-email-input');
|
|
const submit = card.querySelector('#auth-gate-email-submit');
|
|
const status = card.querySelector('#auth-gate-email-status');
|
|
const back = card.querySelector('#auth-gate-back');
|
|
|
|
submit.addEventListener('click', async () => {
|
|
const email = (input.value || '').trim();
|
|
if (!email || !email.includes('@')) {
|
|
status.textContent = 'Enter a valid email address.';
|
|
status.style.color = 'var(--error, #d33)';
|
|
return;
|
|
}
|
|
submit.disabled = true;
|
|
status.textContent = 'Sending…';
|
|
status.style.color = '';
|
|
try {
|
|
const res = await fetch('/api/v1/auth/login/email/initiate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email }),
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
const via = data.deliveredVia || 'email';
|
|
const masked = data.maskedEmail || email;
|
|
if (via === 'dev-console') {
|
|
status.innerHTML = `
|
|
Check the server logs for your one-time link.
|
|
(Dev mode: no SMTP configured. In production this would email <strong>${masked}</strong>.)`;
|
|
} else {
|
|
status.innerHTML = `Sign-in link sent to <strong>${masked}</strong>.
|
|
Check your inbox (and spam folder).`;
|
|
}
|
|
status.style.color = 'var(--success, #2a7)';
|
|
} else {
|
|
status.textContent = data.error || 'Could not send link.';
|
|
status.style.color = 'var(--error, #d33)';
|
|
submit.disabled = false;
|
|
}
|
|
} catch (e) {
|
|
status.textContent = 'Connection error. Try again.';
|
|
status.style.color = 'var(--error, #d33)';
|
|
submit.disabled = false;
|
|
}
|
|
});
|
|
|
|
input.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') submit.click();
|
|
});
|
|
|
|
back.addEventListener('click', e => {
|
|
e.preventDefault();
|
|
showProviderSelector(providers);
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Unknown provider → restore TOTP path as a last resort
|
|
console.warn('[auth-gate] unknown provider', provider.name);
|
|
window.location.reload();
|
|
}
|
|
|
|
async function show() {
|
|
const overlay = document.getElementById('totp-overlay');
|
|
if (!overlay) return; // TOTP-only build without our overlay changes
|
|
|
|
const providers = await fetchMethods();
|
|
if (providers.length === 0) {
|
|
// Either the endpoint isn't reachable OR no provider reports enabled.
|
|
// Fall back to the legacy TOTP overlay — existing totp-auth.js handles it.
|
|
if (typeof window._showTotpOverlay === 'function') window._showTotpOverlay();
|
|
return;
|
|
}
|
|
if (providers.length === 1 && providers[0].name === 'totp') {
|
|
// Single TOTP provider → show the original TOTP overlay unchanged,
|
|
// but with an "Or sign in with email" link below so the email path
|
|
// is reachable as the recovery / phone-friendly alternative. Most
|
|
// users still want their primary method (TOTP) front-and-center.
|
|
showTotpWithEmailFallback(providers[0]);
|
|
return;
|
|
}
|
|
showProviderSelector(providers);
|
|
}
|
|
|
|
function showTotpWithEmailFallback(totpProvider) {
|
|
const emailEnabled = methodsCache && methodsCache.find(p => p.name === 'email');
|
|
if (!emailEnabled) {
|
|
// Truly single-provider path: legacy TOTP overlay, no alt link.
|
|
if (typeof window._showTotpOverlay === 'function') window._showTotpOverlay();
|
|
return;
|
|
}
|
|
const overlay = document.getElementById('totp-overlay');
|
|
const card = overlay.querySelector('.totp-card');
|
|
if (!card) return;
|
|
|
|
// Save the original TOTP markup so we can restore on alt-link click off.
|
|
if (!card.dataset.originalBody) card.dataset.originalBody = card.innerHTML;
|
|
|
|
// Add a small "or" link at the bottom of the existing card WITHOUT
|
|
// touching the TOTP input markup — keeps totp-auth.js's submitTotpCode
|
|
// binding intact.
|
|
let alt = card.querySelector('#auth-gate-email-alt');
|
|
if (!alt) {
|
|
const div = document.createElement('div');
|
|
div.id = 'auth-gate-email-alt';
|
|
div.style.cssText = 'margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 0.85rem;';
|
|
div.innerHTML = `<a href="#" id="auth-gate-email-alt-link"
|
|
style="color: var(--accent); text-decoration: none;">
|
|
Or sign in with email instead →
|
|
</a>`;
|
|
card.appendChild(div);
|
|
div.querySelector('#auth-gate-email-alt-link').addEventListener('click', e => {
|
|
e.preventDefault();
|
|
renderProviderChallenge(emailEnabled);
|
|
});
|
|
}
|
|
overlay.classList.add('show');
|
|
}
|
|
|
|
// ---- Trigger points ----
|
|
// 1. SSO redirect from Caddy: ?auth=required
|
|
// We claim ownership here (set window.__dc_049_handled = true)
|
|
// BEFORE totp-auth.js's own ?auth=required check runs, so the legacy
|
|
// TOTP-only overlay doesn't flicker in for multi-provider installs.
|
|
// Single-provider TOTP-only installs work because we still delegate
|
|
// back to window._showTotpOverlay() in `show()` below.
|
|
window.__dc_049_handled = true;
|
|
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
if (urlParams.get('auth') === 'required') {
|
|
// Save returnUrl the same way totp-auth.js does, so both paths share state.
|
|
// We don't have access to the SITE constant here (it lives in globals.js's
|
|
// module scope), so we use a conservative origin-only check. Caddy's
|
|
// forward_auth already validates the request origin upstream.
|
|
const returnUrl = urlParams.get('return');
|
|
if (returnUrl) {
|
|
try {
|
|
const parsed = new URL(returnUrl, window.location.origin);
|
|
if (parsed.origin === window.location.origin) {
|
|
try { sessionStorage.setItem('totp_redirect', returnUrl); } catch (_) {}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
// Clean URL — happens after we've captured the redirect
|
|
window.history.replaceState({}, '', window.location.pathname);
|
|
// Show on next tick so the DOM (the .totp-card) is ready
|
|
setTimeout(show, 0);
|
|
}
|
|
|
|
// Expose for hot-trigger from other modules (e.g. logout)
|
|
window._showAuthGate = show;
|
|
window._authGateMethods = fetchMethods;
|
|
})();
|