DC-049 auth gate UI: pluggable provider selector + email challenge
New module status/js/auth-gate.js owns the Caddy ?auth=required flow. On load it queries GET /api/v1/auth/login/methods to discover which AuthProviders are configured. Three branches: * 0 providers → legacy TOTP overlay (delegates to window._showTotpOverlay) * 1 provider (totp only) → legacy TOTP overlay (delegates, no UI change) * 2+ providers → provider selector with 'Sign in with …' buttons Email provider challenge is a single email input + 'Send sign-in link' button. POST to /api/v1/auth/login/email/initiate. On success the UI shows 'check the server logs' message if deliveredVia == 'dev-console' (production hosts without SMTP fall back gracefully) or 'check your inbox' when SMTP is configured. TOTP button just calls window.location.reload() — simplest path because totp-auth.js wires the 6-digit input handlers at module-load time, and a reload re-runs all IIFEs with the original markup. Same behavior as the legacy single-provider path. Coordination with totp-auth.js: auth-gate.js sets window.__dc_049_handled = true at IIFE entry. totp-auth.js's top-level ?auth=required check reads that flag and skips its own UI when set — eliminates the flicker in multi-provider installs. Single-provider installs still work because the legacy code path is unchanged (auth-gate delegates to it). Bundle order in build.js: auth-gate.js BEFORE totp-auth.js so the flag is set in time. Webpack-style bundle markers verified offline: __dc_049_handled, auth-gate-email-input, provider-btn, _showAuthGate, totp_redirect all present in dist/core.js (now 20 files, 248KB raw / 153KB min). New SW cache hash dashcaddy-shell-680e230383 (was 743f9c17b0).
This commit is contained in:
@@ -18,6 +18,12 @@ const bundles = {
|
|||||||
JS('globals.js'),
|
JS('globals.js'),
|
||||||
JS('skeleton-loader.js'),
|
JS('skeleton-loader.js'),
|
||||||
JS('theme.js'),
|
JS('theme.js'),
|
||||||
|
// DC-049: pluggable auth gate — claims ownership of the
|
||||||
|
// ?auth=required flow by setting window.__dc_049_handled BEFORE
|
||||||
|
// totp-auth.js runs, so the legacy TOTP-only overlay doesn't flicker
|
||||||
|
// in for multi-provider installs. Single-provider TOTP-only installs
|
||||||
|
// work because this module delegates back to window._showTotpOverlay().
|
||||||
|
JS('auth-gate.js'),
|
||||||
JS('totp-auth.js'),
|
JS('totp-auth.js'),
|
||||||
// totp-recovery.js registers window._refreshRecoveryLink which totp-auth.js
|
// totp-recovery.js registers window._refreshRecoveryLink which totp-auth.js
|
||||||
// calls from showTotpOverlay(). Must come after totp-auth.js.
|
// calls from showTotpOverlay(). Must come after totp-auth.js.
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
// ===== 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 only TOTP is enabled (which
|
||||||
|
// isEnabled() returns false until set up). Either way, 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
|
||||||
|
if (typeof window._showTotpOverlay === 'function') window._showTotpOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showProviderSelector(providers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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;
|
||||||
|
})();
|
||||||
@@ -111,9 +111,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle ?auth=required redirect from Caddy SSO
|
// Handle ?auth=required redirect from Caddy SSO.
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
// DC-049: if the auth-gate module will handle this (multi-provider
|
||||||
if (urlParams.get('auth') === 'required') {
|
// installs), skip our TOTP-only branch entirely. Single-provider
|
||||||
|
// TOTP-only installs continue to use this path because auth-gate.js
|
||||||
|
// delegates back to window._showTotpOverlay().
|
||||||
|
const __dc_049_skip = !!(window.__dc_049_handled);
|
||||||
|
if (!__dc_049_skip && urlParams.get('auth') === 'required') {
|
||||||
const returnUrl = urlParams.get('return');
|
const returnUrl = urlParams.get('return');
|
||||||
if (returnUrl) {
|
if (returnUrl) {
|
||||||
// Validate redirect URL: must be same-origin or hostname must end with our TLD
|
// Validate redirect URL: must be same-origin or hostname must end with our TLD
|
||||||
|
|||||||
Reference in New Issue
Block a user