/** * UltiSuite — enrichissements DOM des flows Authentik (shadow roots inclus). * Actuellement : ulti-enrollment (suffixe e-mail, toggle mot de passe, robustesse). */ (function () { "use strict"; const ENROLLMENT_SLUG = "ulti-enrollment"; const DEFAULT_MAIL_SUFFIX = "@ultisuite.fr"; const EMAIL_CHECK_DEBOUNCE_MS = 1000; const MIN_EMAIL_LOCAL_LENGTH = 2; const EYE_SVG = ''; const EYE_OFF_SVG = ''; function forEachDeep(root, visit) { if (!root) return; visit(root); const nodes = root.querySelectorAll ? root.querySelectorAll("*") : []; for (const node of nodes) { if (node.shadowRoot) forEachDeep(node.shadowRoot, visit); } } function flowSlug(executor) { return ( executor.getAttribute("flowslug") || executor.getAttribute("flowSlug") || "" ); } function mailSuffixFromForm(form) { const hidden = form.querySelector('input[name="email"]'); const raw = hidden?.value || hidden?.getAttribute("value") || ""; const at = raw.indexOf("@"); if (at >= 0) return raw.slice(at); return DEFAULT_MAIL_SUFFIX; } function mailDomainFromForm(form) { const suffix = mailSuffixFromForm(form); return suffix.replace(/^@+/, ""); } function normalizeSignupLocalPart(raw) { return String(raw || "") .replace(/@+/g, "") .trim() .toLowerCase(); } function evaluatePasswordStrength(password) { if (!password) return { level: "empty", label: "" }; let score = 0; if (password.length >= 8) score += 1; if (password.length >= 12) score += 1; if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score += 1; if (/\d/.test(password)) score += 1; if (/[^A-Za-z0-9]/.test(password)) score += 1; if (score <= 1) return { level: "weak", label: "Faible" }; if (score === 2) return { level: "fair", label: "Moyen" }; if (score === 3 || score === 4) return { level: "good", label: "Bon" }; return { level: "strong", label: "Fort" }; } function activeSegments(level) { if (level === "weak") return 1; if (level === "fair") return 2; if (level === "good") return 3; if (level === "strong") return 4; return 0; } function createStrengthMeter(input) { const wrap = document.createElement("div"); wrap.className = "ulti-password-strength"; wrap.hidden = true; wrap.setAttribute("aria-live", "polite"); const bars = document.createElement("div"); bars.className = "ulti-password-strength-bars"; for (let i = 0; i < 4; i += 1) { bars.appendChild(document.createElement("span")); } const label = document.createElement("p"); label.className = "ulti-password-strength-label"; label.innerHTML = 'Robustesse : '; wrap.appendChild(bars); wrap.appendChild(label); const update = () => { const { level, label: text } = evaluatePasswordStrength(input.value); wrap.hidden = !input.value; const segs = activeSegments(level); bars.querySelectorAll("span").forEach((bar, index) => { bar.className = index < segs ? `active ${level}` : ""; }); wrap.querySelector(".ulti-password-strength-value").textContent = text; }; input.addEventListener("input", update); update(); return wrap; } function createMatchIndicator(primary, repeat) { const el = document.createElement("p"); el.className = "ulti-password-match"; el.hidden = true; el.setAttribute("role", "status"); const update = () => { const a = primary.value; const b = repeat.value; if (!b) { el.hidden = true; return; } el.hidden = false; const ok = a === b; el.className = ok ? "ulti-password-match ok" : "ulti-password-match bad"; el.textContent = ok ? "Les mots de passe correspondent." : "Les mots de passe ne correspondent pas."; }; primary.addEventListener("input", update); repeat.addEventListener("input", update); update(); return el; } function wrapPasswordInput(input) { if (input.closest(".ulti-password-wrap")) return input.closest(".ulti-password-wrap"); const wrap = document.createElement("div"); wrap.className = "ulti-password-wrap"; input.parentNode.insertBefore(wrap, input); wrap.appendChild(input); input.classList.add("ulti-password-input"); const btn = document.createElement("button"); btn.type = "button"; btn.className = "ulti-password-toggle"; btn.setAttribute("aria-label", "Afficher le mot de passe"); btn.innerHTML = EYE_SVG; btn.addEventListener("click", () => { const visible = input.type === "text"; input.type = visible ? "password" : "text"; btn.setAttribute( "aria-label", visible ? "Afficher le mot de passe" : "Masquer le mot de passe" ); btn.innerHTML = visible ? EYE_SVG : EYE_OFF_SVG; }); wrap.appendChild(btn); return wrap; } function enhanceEmailField(form) { const input = form.querySelector('input[name="username"]'); if (!input || input.closest(".ulti-email-input-group")) return; form.querySelectorAll("p").forEach((p) => { if (p.textContent.trim().startsWith("@")) p.hidden = true; }); const group = document.createElement("div"); group.className = "ulti-email-input-group"; input.parentNode.insertBefore(group, input); group.appendChild(input); const suffix = document.createElement("span"); suffix.className = "ulti-email-suffix"; suffix.textContent = mailSuffixFromForm(form); group.appendChild(suffix); attachEmailAvailabilityChecker(form, input); } function attachEmailAvailabilityChecker(form, input) { if (input.dataset.ultiAvailability === "1") return; input.dataset.ultiAvailability = "1"; const hint = document.createElement("p"); hint.className = "ulti-email-availability"; hint.hidden = true; hint.setAttribute("aria-live", "polite"); const host = input.closest("ak-form-element"); if (host) host.after(hint); let debounceTimer = null; let requestId = 0; let debouncedLocal = ""; let status = "idle"; function submitButton() { return form.querySelector( '.pf-c-form__group.pf-m-action button[type="submit"]' ); } function setSubmitDisabled(disabled) { const btn = submitButton(); if (!btn) return; btn.disabled = disabled; btn.setAttribute("aria-disabled", disabled ? "true" : "false"); } function render(local) { if (local.length === 0) { hint.hidden = true; input.removeAttribute("aria-invalid"); setSubmitDisabled(false); return; } hint.hidden = false; hint.className = "ulti-email-availability"; if (local.length < MIN_EMAIL_LOCAL_LENGTH) { hint.classList.add("muted"); hint.textContent = "Au moins 2 caractères avant le @."; input.removeAttribute("aria-invalid"); setSubmitDisabled(true); return; } if (local !== debouncedLocal || status === "checking") { hint.classList.add("checking"); hint.textContent = "Vérification de la disponibilité…"; input.removeAttribute("aria-invalid"); setSubmitDisabled(true); return; } if (status === "available") { hint.classList.add("ok"); hint.setAttribute("role", "status"); hint.textContent = "Cette adresse est disponible."; input.removeAttribute("aria-invalid"); setSubmitDisabled(false); return; } if (status === "taken") { hint.classList.add("bad"); hint.setAttribute("role", "alert"); hint.textContent = "Cette adresse est déjà prise."; input.setAttribute("aria-invalid", "true"); setSubmitDisabled(true); return; } if (status === "error") { hint.classList.add("muted"); hint.textContent = "Impossible de vérifier la disponibilité pour le moment."; input.removeAttribute("aria-invalid"); setSubmitDisabled(false); return; } hint.hidden = true; setSubmitDisabled(false); } async function runCheck(local) { const domain = mailDomainFromForm(form); const id = ++requestId; status = "checking"; render(local); try { const params = new URLSearchParams({ local, domain }); const res = await fetch( `/api/v1/mail/addresses/check?${params.toString()}`, { headers: { Accept: "application/json" }, credentials: "include", } ); if (id !== requestId) return; if (!res.ok) throw new Error(`address check failed (${res.status})`); const data = await res.json(); status = data.available ? "available" : "taken"; } catch { if (id !== requestId) return; status = "error"; } render(normalizeSignupLocalPart(input.value)); } function scheduleCheck() { const local = normalizeSignupLocalPart(input.value); window.clearTimeout(debounceTimer); if (local.length < MIN_EMAIL_LOCAL_LENGTH) { requestId += 1; debouncedLocal = local; status = "idle"; render(local); return; } status = "checking"; render(local); debounceTimer = window.setTimeout(() => { debouncedLocal = local; void runCheck(local); }, EMAIL_CHECK_DEBOUNCE_MS); } input.addEventListener("input", scheduleCheck); input.addEventListener("keydown", (event) => { if (event.key === "@") event.preventDefault(); }); input.addEventListener("paste", (event) => { const text = event.clipboardData?.getData("text") ?? ""; if (!text.includes("@")) return; event.preventDefault(); input.value = normalizeSignupLocalPart(text); input.dispatchEvent(new Event("input", { bubbles: true })); }); scheduleCheck(); } function enhanceEnrollmentForm(form) { if (form.dataset.ultiEnhanced === "1") return; if (!form.querySelector('input[name="username"]')) return; form.dataset.ultiEnhanced = "1"; enhanceEmailField(form); const password = form.querySelector('input[name="password"]'); const repeat = form.querySelector('input[name="password_repeat"]'); if (!password || !repeat) return; wrapPasswordInput(password); wrapPasswordInput(repeat); const passwordHost = password.closest("ak-form-element"); if (passwordHost && !form.querySelector(".ulti-password-strength")) { passwordHost.after(createStrengthMeter(password)); } const repeatHost = repeat.closest("ak-form-element"); if (repeatHost && !form.querySelector(".ulti-password-match")) { repeatHost.after(createMatchIndicator(password, repeat)); } } function scan() { const executor = document.querySelector("ak-flow-executor"); if (!executor || flowSlug(executor) !== ENROLLMENT_SLUG) return false; let found = false; if (executor.shadowRoot) { forEachDeep(executor.shadowRoot, (root) => { root.querySelectorAll?.("form.pf-c-form")?.forEach((form) => { found = true; enhanceEnrollmentForm(form); }); }); } return found; } function observeExecutor(executor) { if (!executor?.shadowRoot || executor.dataset.ultiObserved === "1") return; executor.dataset.ultiObserved = "1"; new MutationObserver(() => scan()).observe(executor.shadowRoot, { childList: true, subtree: true, }); } function boot() { let tries = 0; const tick = () => { tries += 1; const executor = document.querySelector("ak-flow-executor"); observeExecutor(executor); scan(); const enhanced = (() => { const executor = document.querySelector("ak-flow-executor"); if (!executor?.shadowRoot) return null; let match = null; forEachDeep(executor.shadowRoot, (root) => { match = match || root.querySelector?.('form.pf-c-form[data-ulti-enhanced="1"]') || null; }); return match; })(); if (!enhanced && tries < 80) { window.setTimeout(tick, 150); } }; tick(); new MutationObserver(() => { observeExecutor(document.querySelector("ak-flow-executor")); scan(); }).observe(document.body, { childList: true, subtree: true }); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", boot); } else { boot(); } })();