- Added a new blueprint for OIDC logout that invalidates the Authentik session and redirects to a specified landing page. - Introduced custom CSS and JS files for branding, improving the visual integration of Authentik flows. - Updated Nginx configuration to serve the new branding assets and handle specific routes for signup and password recovery. - Enhanced the flow completion logic to support OIDC bridge functionality, including session management and redirect handling. - Implemented unit tests for the new OIDC bridge and flow context functionalities to ensure reliability.
426 lines
13 KiB
JavaScript
426 lines
13 KiB
JavaScript
/**
|
|
* 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 =
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>';
|
|
const EYE_OFF_SVG =
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" x2="22" y1="2" y2="22"/></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 : <strong class="ulti-password-strength-value"></strong>';
|
|
|
|
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();
|
|
}
|
|
})();
|