DC-049 follow-on: email fallback link on TOTP-only overlay
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

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.
This commit is contained in:
Hermes Agent
2026-07-20 02:21:59 -07:00
parent 923ce8c300
commit c54739e110
+42 -5
View File
@@ -189,20 +189,57 @@
const providers = await fetchMethods(); const providers = await fetchMethods();
if (providers.length === 0) { if (providers.length === 0) {
// Either the endpoint isn't reachable OR only TOTP is enabled (which // Either the endpoint isn't reachable OR no provider reports enabled.
// isEnabled() returns false until set up). Either way, fall back to // Fall back to the legacy TOTP overlay — existing totp-auth.js handles it.
// the legacy TOTP overlay — existing totp-auth.js handles it.
if (typeof window._showTotpOverlay === 'function') window._showTotpOverlay(); if (typeof window._showTotpOverlay === 'function') window._showTotpOverlay();
return; return;
} }
if (providers.length === 1 && providers[0].name === 'totp') { if (providers.length === 1 && providers[0].name === 'totp') {
// Single TOTP provider → show the original TOTP overlay unchanged // Single TOTP provider → show the original TOTP overlay unchanged,
if (typeof window._showTotpOverlay === 'function') window._showTotpOverlay(); // 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; return;
} }
showProviderSelector(providers); 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 ---- // ---- Trigger points ----
// 1. SSO redirect from Caddy: ?auth=required // 1. SSO redirect from Caddy: ?auth=required
// We claim ownership here (set window.__dc_049_handled = true) // We claim ownership here (set window.__dc_049_handled = true)