399 lines
14 KiB
JavaScript
Executable file
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');
|
|
}
|