loreal-sla-calculator/auth.js
Vadym Samoilenko 53a73cb9c9 Fix SSO redirect URI: remove trailing slash to match Azure registration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 16:28:11 +00:00

399 lines
14 KiB
JavaScript
Executable file

// =============================================================================
// Auth — MSAL SSO + Email/Password + Token management
// =============================================================================
const msalConfig = {
auth: {
clientId: '9079054c-9620-4757-a256-23413042f1ef',
authority: 'https://login.microsoftonline.com/e519c2e6-bc6d-4fdf-8d9c-923c2f002385',
redirectUri: window.location.origin + '/loreal-sla-calculator',
},
cache: {
cacheLocation: 'sessionStorage',
storeAuthStateInCookie: false,
},
};
const loginRequest = { scopes: ['User.Read'] };
const API_BASE = '/loreal-sla-calculator/api';
// Encode password to base64 (unicode-safe) to avoid WAF issues with special chars
function encodePassword(pw) {
return btoa(unescape(encodeURIComponent(pw)));
}
let msalInstance = null;
let currentAccessToken = null; // In-memory only — never persisted
let pendingResetToken = null; // ?reset_token= from URL
let pendingVerifyEmail = null; // email address waiting for re-send
// ── View switcher ─────────────────────────────────────────────────────────────
function showAuthView(id) {
document.querySelectorAll('.auth-view').forEach(el => el.classList.add('hidden'));
const el = document.getElementById(`authView-${id}`);
if (el) el.classList.remove('hidden');
}
// ── Entry point ───────────────────────────────────────────────────────────────
async function initAuth() {
// LOCAL DEV: skip auth on localhost / 127.0.0.1
if (['localhost', '127.0.0.1'].includes(location.hostname)) {
onAuthSuccess({ email: 'dev@localhost', displayName: 'Local Dev' }, 'dev');
return;
}
// 1. Handle ?verify_token= in URL
const urlParams = new URLSearchParams(window.location.search);
const verifyTok = urlParams.get('verify_token');
const resetTok = urlParams.get('reset_token');
if (verifyTok) {
history.replaceState({}, '', window.location.pathname);
await handleVerifyEmail(verifyTok);
return;
}
if (resetTok) {
history.replaceState({}, '', window.location.pathname);
await handleResetTokenParam(resetTok);
return;
}
// 2. Initialise MSAL and handle redirect response (non-blocking for email users)
try {
msalInstance = new msal.PublicClientApplication(msalConfig);
const msalResponse = await msalInstance.handleRedirectPromise();
if (msalResponse && msalResponse.idToken) {
await exchangeMsalToken(msalResponse.idToken);
const returnUrl = sessionStorage.getItem('sso_return_url');
if (returnUrl && returnUrl !== window.location.href) {
sessionStorage.removeItem('sso_return_url');
window.location.replace(returnUrl);
}
return;
}
} catch (e) {
console.warn('MSAL init error:', e);
}
// 3. Try silent refresh via httpOnly cookie (returning user)
const refreshed = await tryRefresh();
if (refreshed) return;
// 4. Nothing worked — show auth choice
showAuthView('choice');
}
// ── Token refresh ─────────────────────────────────────────────────────────────
async function tryRefresh() {
try {
const res = await fetch(`${API_BASE}/auth/refresh`, { method: 'POST', credentials: 'include' });
if (!res.ok) return false;
const data = await res.json();
currentAccessToken = data.accessToken;
onAuthSuccess(data.user, 'refresh');
return true;
} catch {
return false;
}
}
// Refresh every 14 minutes while the page is open
setInterval(async () => {
if (currentAccessToken) await tryRefresh();
}, 14 * 60 * 1000);
// ── SSO path ──────────────────────────────────────────────────────────────────
async function authSsoLogin() {
try {
if (!msalInstance) {
msalInstance = new msal.PublicClientApplication(msalConfig);
}
// Try silent SSO first; if it fails, do a full redirect
try {
const silent = await msalInstance.ssoSilent(loginRequest);
await exchangeMsalToken(silent.idToken);
} catch {
sessionStorage.setItem('sso_return_url', window.location.href);
await msalInstance.loginRedirect(loginRequest);
}
} catch (e) {
console.error('SSO login error:', e);
showAuthView('choice');
}
}
async function exchangeMsalToken(idToken) {
showAuthView('loading');
try {
const res = await fetch(`${API_BASE}/auth/sso`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ idToken }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'SSO failed');
currentAccessToken = data.accessToken;
onAuthSuccess(data.user, 'sso');
} catch (err) {
console.error('SSO exchange error:', err);
showAuthView('choice');
}
}
// ── Email/password path ───────────────────────────────────────────────────────
async function authEmailLogin(event) {
event.preventDefault();
const email = document.getElementById('loginEmail').value.trim();
const password = document.getElementById('loginPassword').value;
const errEl = document.getElementById('authLoginError');
const btn = document.getElementById('loginSubmitBtn');
errEl.classList.add('hidden');
btn.disabled = true;
btn.textContent = 'Signing in…';
try {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password: encodePassword(password) }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Login failed');
currentAccessToken = data.accessToken;
onAuthSuccess(data.user, 'email');
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
} finally {
btn.disabled = false;
btn.textContent = 'Sign in';
}
}
// ── Register ──────────────────────────────────────────────────────────────────
async function authRegister(event) {
event.preventDefault();
const email = document.getElementById('registerEmail').value.trim();
const password = document.getElementById('registerPassword').value;
const confirm = document.getElementById('registerConfirm').value;
const errEl = document.getElementById('authRegisterError');
const btn = document.getElementById('registerSubmitBtn');
errEl.classList.add('hidden');
if (password !== confirm) {
errEl.textContent = 'Passwords do not match.';
errEl.classList.remove('hidden');
return;
}
btn.disabled = true;
btn.textContent = 'Creating account…';
try {
const res = await fetch(`${API_BASE}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password: encodePassword(password) }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Registration failed');
pendingVerifyEmail = email;
showAuthView('verify');
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
} finally {
btn.disabled = false;
btn.textContent = 'Create account';
}
}
// ── Forgot password ───────────────────────────────────────────────────────────
async function authForgotPassword(event) {
event.preventDefault();
const email = document.getElementById('forgotEmail').value.trim();
const msgEl = document.getElementById('authForgotMsg');
const btn = document.getElementById('forgotSubmitBtn');
btn.disabled = true;
btn.textContent = 'Sending…';
msgEl.className = 'hidden mb-4 p-3 text-sm rounded-lg';
try {
await fetch(`${API_BASE}/auth/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
msgEl.textContent = 'If that email exists, a reset link has been sent. Check your inbox.';
msgEl.className = 'mb-4 p-3 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 text-sm rounded-lg';
} catch {
msgEl.textContent = 'Something went wrong. Please try again.';
msgEl.className = 'mb-4 p-3 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm rounded-lg';
} finally {
btn.disabled = false;
btn.textContent = 'Send reset link';
}
}
// ── Reset password (from link) ────────────────────────────────────────────────
async function handleResetTokenParam(token) {
showAuthView('loading');
try {
const res = await fetch(`${API_BASE}/auth/validate-reset-token/${token}`);
const data = await res.json();
if (!res.ok || !data.valid) {
showAuthView('login');
return;
}
pendingResetToken = token;
showAuthView('reset');
} catch {
showAuthView('choice');
}
}
async function authResetPassword(event) {
event.preventDefault();
const password = document.getElementById('resetPassword').value;
const confirm = document.getElementById('resetConfirm').value;
const errEl = document.getElementById('authResetError');
const btn = document.getElementById('resetSubmitBtn');
errEl.classList.add('hidden');
if (password !== confirm) {
errEl.textContent = 'Passwords do not match.';
errEl.classList.remove('hidden');
return;
}
btn.disabled = true;
btn.textContent = 'Saving…';
try {
const res = await fetch(`${API_BASE}/auth/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: pendingResetToken, password: encodePassword(password) }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Reset failed');
pendingResetToken = null;
showAuthView('resetSuccess');
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
} finally {
btn.disabled = false;
btn.textContent = 'Set new password';
}
}
// ── Verify email (from link) ──────────────────────────────────────────────────
async function handleVerifyEmail(token) {
showAuthView('loading');
try {
const res = await fetch(`${API_BASE}/auth/verify-email/${token}`);
const data = await res.json();
if (res.ok) {
const errEl = document.getElementById('authLoginError');
if (errEl) {
errEl.textContent = 'Email verified! You can now sign in.';
errEl.className = 'mb-4 p-3 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 text-sm rounded-lg';
}
showAuthView('login');
} else {
showAuthView('login');
}
} catch {
showAuthView('login');
}
}
async function authResendVerification() {
if (!pendingVerifyEmail) return;
try {
await fetch(`${API_BASE}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: pendingVerifyEmail, _resend: true }),
});
} catch {
// Silently ignore
}
}
// ── Success / sign-out ────────────────────────────────────────────────────────
function onAuthSuccess(user, authMethod) {
const overlay = document.getElementById('authOverlay');
if (overlay) overlay.style.display = 'none';
const appContainer = document.getElementById('appContainer');
if (appContainer) appContainer.style.display = '';
const userDisplay = document.getElementById('userDisplay');
if (userDisplay) userDisplay.textContent = user.displayName || user.email;
const userInfo = document.getElementById('userInfo');
if (userInfo) userInfo.classList.remove('hidden');
// Track login event (skip on localhost dev bypass)
if (authMethod && authMethod !== 'dev') {
const email = user.email || '';
const domain = email.split('@')[1] || '';
const isLoreal = /loreal|lorealusa/i.test(domain);
fetch(`${API_BASE.replace('/auth', '')}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: 'login',
page: 'calculator',
metadata: { authMethod, emailDomain: domain, isLoreal },
}),
}).catch(() => {});
}
}
async function signOut() {
try {
await fetch(`${API_BASE}/auth/logout`, { method: 'POST', credentials: 'include' });
} catch {
// Ignore network errors on logout
}
currentAccessToken = null;
if (msalInstance) {
try {
await msalInstance.logoutSilent();
} catch {
// MSAL silent logout may fail — that's fine
}
}
const overlay = document.getElementById('authOverlay');
if (overlay) overlay.style.display = '';
const appContainer = document.getElementById('appContainer');
if (appContainer) appContainer.style.display = 'none';
showAuthView('choice');
}