401 lines
11 KiB
JavaScript
401 lines
11 KiB
JavaScript
// public/js/portal-login.js
|
||
|
||
// -------- URL helpers --------
|
||
function sanitizeRedirect(raw, { fallback = '/' } = {}) {
|
||
if (!raw) return fallback;
|
||
try {
|
||
const str = String(raw).trim();
|
||
if (!str) return fallback;
|
||
|
||
// Resolve against current origin so relative URLs work
|
||
const candidate = new URL(str, window.location.origin);
|
||
|
||
// 1) Must stay on the same origin
|
||
if (candidate.origin !== window.location.origin) {
|
||
return fallback;
|
||
}
|
||
|
||
// 2) Only allow http/https
|
||
if (candidate.protocol !== 'http:' && candidate.protocol !== 'https:') {
|
||
return fallback;
|
||
}
|
||
|
||
// Return a relative URL (prevents host changes)
|
||
return candidate.pathname + candidate.search + candidate.hash;
|
||
} catch {
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
function getRedirectTarget() {
|
||
try {
|
||
const url = new URL(window.location.href);
|
||
const raw = url.searchParams.get('redirect');
|
||
|
||
// Default fallback: root
|
||
let target = sanitizeRedirect(raw, { fallback: '/' });
|
||
|
||
// If there was no *usable* redirect but we have a portal slug,
|
||
// send them back to that portal by default.
|
||
if (!target || target === '/') {
|
||
const slug = getPortalSlugFromUrl();
|
||
if (slug) {
|
||
target = sanitizeRedirect('/portal/' + encodeURIComponent(slug), { fallback: '/' });
|
||
}
|
||
}
|
||
|
||
return target || '/';
|
||
} catch {
|
||
return '/';
|
||
}
|
||
}
|
||
|
||
function getPortalSlugFromUrl() {
|
||
try {
|
||
const url = new URL(window.location.href);
|
||
|
||
// 1) Direct ?slug=portal-xxxxx on login page (if ever used)
|
||
let slug = url.searchParams.get('slug');
|
||
if (slug && slug.trim()) {
|
||
console.log('portal-login: slug from top-level param =', slug.trim());
|
||
return slug.trim();
|
||
}
|
||
|
||
// 2) From redirect param: may be portal.html?slug=... or /portal/<slug>
|
||
const redirect = url.searchParams.get('redirect');
|
||
if (redirect) {
|
||
console.log('portal-login: raw redirect param =', redirect);
|
||
|
||
try {
|
||
const redirectUrl = new URL(redirect, window.location.origin);
|
||
|
||
// 2a) ?slug=... in redirect
|
||
const innerSlug = redirectUrl.searchParams.get('slug');
|
||
if (innerSlug && innerSlug.trim()) {
|
||
console.log('portal-login: slug from redirect URL =', innerSlug.trim());
|
||
return innerSlug.trim();
|
||
}
|
||
|
||
// 2b) Pretty path /portal/<slug> in redirect
|
||
const pathMatch = redirectUrl.pathname.match(/\/portal\/([^\/?#]+)/i);
|
||
if (pathMatch && pathMatch[1]) {
|
||
const fromPath = pathMatch[1].trim();
|
||
console.log('portal-login: slug from redirect path =', fromPath);
|
||
return fromPath;
|
||
}
|
||
} catch (err) {
|
||
console.warn('portal-login: failed to parse redirect URL', err);
|
||
}
|
||
|
||
// 2c) Fallback regex on redirect string
|
||
const m = redirect.match(/[?&]slug=([^&]+)/);
|
||
if (m && m[1]) {
|
||
const decoded = decodeURIComponent(m[1]).trim();
|
||
console.log('portal-login: slug from redirect regex =', decoded);
|
||
return decoded;
|
||
}
|
||
}
|
||
|
||
// 3) Legacy fallback on current query string
|
||
const qs = window.location.search || '';
|
||
const m2 = qs.match(/[?&]slug=([^&]+)/);
|
||
if (m2 && m2[1]) {
|
||
const decoded2 = decodeURIComponent(m2[1]).trim();
|
||
console.log('portal-login: slug from own query regex =', decoded2);
|
||
return decoded2;
|
||
}
|
||
|
||
console.log('portal-login: no slug found');
|
||
return '';
|
||
} catch (err) {
|
||
console.warn('portal-login: getPortalSlugFromUrl error', err);
|
||
const qs = window.location.search || '';
|
||
const m = qs.match(/[?&]slug=([^&]+)/);
|
||
return m && m[1] ? decodeURIComponent(m[1]).trim() : '';
|
||
}
|
||
}
|
||
|
||
// --- CSRF helpers (same pattern as portal.js) ---
|
||
function setCsrfToken(token) {
|
||
if (!token) return;
|
||
window.csrfToken = token;
|
||
try {
|
||
localStorage.setItem('csrf', token);
|
||
} catch { /* ignore */ }
|
||
|
||
let meta = document.querySelector('meta[name="csrf-token"]');
|
||
if (!meta) {
|
||
meta = document.createElement('meta');
|
||
meta.name = 'csrf-token';
|
||
document.head.appendChild(meta);
|
||
}
|
||
meta.content = token;
|
||
}
|
||
|
||
function getCsrfToken() {
|
||
return (
|
||
window.csrfToken ||
|
||
(document.querySelector('meta[name="csrf-token"]')?.content) ||
|
||
''
|
||
);
|
||
}
|
||
|
||
async function loadCsrfToken() {
|
||
try {
|
||
const res = await fetch('/api/auth/token.php', {
|
||
method: 'GET',
|
||
credentials: 'include'
|
||
});
|
||
|
||
const hdr = res.headers.get('X-CSRF-Token');
|
||
if (hdr) setCsrfToken(hdr);
|
||
|
||
let body = {};
|
||
try {
|
||
body = await res.json();
|
||
} catch {
|
||
body = {};
|
||
}
|
||
|
||
const token = body.csrf_token || getCsrfToken();
|
||
setCsrfToken(token);
|
||
} catch (e) {
|
||
console.warn('portal-login: failed to load CSRF token', e);
|
||
}
|
||
}
|
||
|
||
// --- UI helpers ---
|
||
function showError(msg) {
|
||
const box = document.getElementById('portalLoginError');
|
||
if (!box) return;
|
||
box.textContent = msg || 'Login failed.';
|
||
box.classList.add('show');
|
||
}
|
||
|
||
function clearError() {
|
||
const box = document.getElementById('portalLoginError');
|
||
if (!box) return;
|
||
box.textContent = '';
|
||
box.classList.remove('show');
|
||
}
|
||
|
||
// -------- Portal meta (title + accent) --------
|
||
async function fetchPortalMeta(slug) {
|
||
if (!slug) return null;
|
||
console.log('portal-login: calling publicMeta.php for slug', slug);
|
||
try {
|
||
const res = await fetch(
|
||
'/api/pro/portals/publicMeta.php?slug=' + encodeURIComponent(slug),
|
||
{ method: 'GET', credentials: 'include' }
|
||
);
|
||
const text = await res.text();
|
||
let data = {};
|
||
try {
|
||
data = text ? JSON.parse(text) : {};
|
||
} catch {
|
||
data = {};
|
||
}
|
||
if (!res.ok || !data || !data.success || !data.portal) {
|
||
console.warn('portal-login: publicMeta not ok', res.status, data);
|
||
return null;
|
||
}
|
||
return data.portal;
|
||
} catch (e) {
|
||
console.warn('portal-login: failed to load portal meta', e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function applyPortalBranding(portal) {
|
||
if (!portal) return;
|
||
|
||
const title =
|
||
(portal.title && portal.title.trim()) ||
|
||
portal.label ||
|
||
portal.slug ||
|
||
'Client portal';
|
||
|
||
const headingEl = document.getElementById('portalLoginTitle');
|
||
const subtitleEl = document.getElementById('portalLoginSubtitle');
|
||
const footerEl = document.getElementById('portalLoginFooter');
|
||
const logoEl = document.getElementById('portalLoginLogo');
|
||
|
||
if (headingEl) {
|
||
headingEl.textContent = 'Sign in to ' + title;
|
||
}
|
||
if (subtitleEl) {
|
||
subtitleEl.textContent = 'to access this client portal';
|
||
}
|
||
|
||
// Footer text from portal metadata, if provided
|
||
if (footerEl) {
|
||
const ft = (portal.footerText && portal.footerText.trim()) || '';
|
||
if (ft) {
|
||
footerEl.textContent = ft;
|
||
footerEl.style.display = 'block';
|
||
} else {
|
||
footerEl.textContent = '';
|
||
footerEl.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// 🔹 Portal logo: use logoFile from metadata if present
|
||
if (logoEl) {
|
||
let logoSrc = null;
|
||
|
||
// If you ever decide to store a direct URL:
|
||
if (portal.logoUrl && portal.logoUrl.trim()) {
|
||
logoSrc = portal.logoUrl.trim();
|
||
} else if (portal.logoFile && portal.logoFile.trim()) {
|
||
// Same convention as portal.html: files live in uploads/profile_pics
|
||
logoSrc = '/uploads/profile_pics/' + portal.logoFile.trim();
|
||
}
|
||
|
||
if (logoSrc) {
|
||
logoEl.src = logoSrc;
|
||
logoEl.alt = title;
|
||
}
|
||
}
|
||
|
||
// Document title
|
||
try {
|
||
document.title = 'Sign in – ' + title;
|
||
} catch { /* ignore */ }
|
||
|
||
// Accent: portal brandColor -> CSS var
|
||
const brand = portal.brandColor && portal.brandColor.trim();
|
||
if (brand) {
|
||
document.documentElement.style.setProperty('--portal-accent', brand);
|
||
}
|
||
|
||
// Reapply card/button accent after we know portal color
|
||
applyAccentFromTheme();
|
||
}
|
||
|
||
// --- Accent (card + button) ---
|
||
function applyAccentFromTheme() {
|
||
const card = document.querySelector('.portal-login-card');
|
||
const btn = document.getElementById('portalLoginSubmit');
|
||
const rootStyles = getComputedStyle(document.documentElement);
|
||
|
||
// Prefer per-portal accent if present
|
||
let accent = rootStyles.getPropertyValue('--portal-accent').trim();
|
||
if (!accent) {
|
||
accent = rootStyles.getPropertyValue('--filr-accent-500').trim() || '#0b5ed7';
|
||
}
|
||
|
||
if (card) {
|
||
card.style.borderTop = `3px solid ${accent}`;
|
||
}
|
||
if (btn) {
|
||
btn.style.backgroundColor = accent;
|
||
btn.style.borderColor = accent;
|
||
}
|
||
|
||
const metaTheme = document.querySelector('meta[name="theme-color"]');
|
||
if (metaTheme) {
|
||
metaTheme.setAttribute('content', accent);
|
||
}
|
||
}
|
||
|
||
// --- Login call (JSON -> auth.php) ---
|
||
async function doLogin(username, password) {
|
||
const csrf = getCsrfToken() || '';
|
||
|
||
const payload = {
|
||
username,
|
||
password
|
||
};
|
||
if (csrf) {
|
||
payload.csrf_token = csrf;
|
||
}
|
||
|
||
const res = await fetch('/api/auth/auth.php', {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: {
|
||
'X-CSRF-Token': csrf,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
const text = await res.text();
|
||
let body = {};
|
||
try {
|
||
body = text ? JSON.parse(text) : {};
|
||
} catch {
|
||
body = {};
|
||
}
|
||
|
||
if (!res.ok) {
|
||
const msg = body.error || body.message || text || 'Login failed.';
|
||
const err = new Error(msg);
|
||
err.status = res.status;
|
||
throw err;
|
||
}
|
||
|
||
if (body.success === false || body.error || body.logged_in === false) {
|
||
throw new Error(body.error || 'Invalid username or password.');
|
||
}
|
||
|
||
return body;
|
||
}
|
||
|
||
// --- Init ---
|
||
document.addEventListener('DOMContentLoaded', async () => {
|
||
const form = document.getElementById('portalLoginForm');
|
||
const userEl = document.getElementById('portalLoginUser');
|
||
const passEl = document.getElementById('portalLoginPass');
|
||
const btn = document.getElementById('portalLoginSubmit');
|
||
|
||
// Accent first (fallback to global accent)
|
||
applyAccentFromTheme();
|
||
|
||
// Try to load portal meta (title + brand color) using slug
|
||
const slug = getPortalSlugFromUrl();
|
||
console.log('portal-login: computed slug =', slug);
|
||
if (slug) {
|
||
fetchPortalMeta(slug).then(portal => {
|
||
if (portal) {
|
||
console.log('portal-login: got portal meta for', slug, portal);
|
||
applyPortalBranding(portal);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Pre-load CSRF (for auth.php)
|
||
loadCsrfToken().catch(() => {});
|
||
|
||
if (!form || !userEl || !passEl || !btn) return;
|
||
|
||
// Focus username
|
||
userEl.focus();
|
||
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
clearError();
|
||
|
||
const username = userEl.value.trim();
|
||
const password = passEl.value;
|
||
|
||
if (!username || !password) {
|
||
showError('Username and password are required');
|
||
return;
|
||
}
|
||
|
||
btn.disabled = true;
|
||
btn.textContent = 'Signing in…';
|
||
|
||
try {
|
||
await doLogin(username, password);
|
||
const target = getRedirectTarget();
|
||
window.location.href = target;
|
||
} catch (err) {
|
||
console.error('portal-login: auth failed', err);
|
||
showError(err.message || 'Login failed. Please try again.');
|
||
btn.disabled = false;
|
||
btn.textContent = 'Sign in';
|
||
}
|
||
});
|
||
}); |