/ src / scripts / register_validator.js
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Sarika Padmashali (tweaks cpollett)
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
/*
 * Localized strings handed to this script by the page that loads it. Only
 * the create-account form sets these; the shared password-requirements
 * code below does not use them, so other forms can load this script too.
 */
var tl = document.tl;
/*
 * How long the brief top-of-screen success note stays up, in milliseconds.
 */
var success_message_time = 5000;
/*
 * Returns the list of password-policy rules the given value breaks, using
 * a rules object read from a password-requirements hint. The list is empty
 * when the value satisfies every rule. This mirrors the server-side
 * passwordPolicyViolations so the browser and the server agree on what an
 * acceptable password is.
 */
function passwordPolicyViolations(value, rules)
{
    var violations = [];
    if (value.length < rules.min) {
        violations.push("too_short");
    }
    if (value.length > rules.max) {
        violations.push("too_long");
    }
    if (rules.lower && !(/[a-z]/.test(value))) {
        violations.push("lowercase");
    }
    if (rules.upper && !(/[A-Z]/.test(value))) {
        violations.push("uppercase");
    }
    if (rules.digit && !(/[0-9]/.test(value))) {
        violations.push("digit");
    }
    if (rules.symbol && !(/[^A-Za-z0-9]/.test(value))) {
        violations.push("symbol");
    }
    for (var index = 0; index < rules.forbidden.length; index++) {
        if (value.indexOf(rules.forbidden.charAt(index)) >= 0) {
            violations.push("forbidden");
            break;
        }
    }
    return violations;
}
/*
 * Reads the rule set out of a password-requirements hint's data attributes
 * into a plain object the checker above understands. The forbidden
 * characters travel as space-separated character codes so no quote ever
 * has to sit inside the markup.
 */
function passwordRulesFromContainer(container)
{
    var codes = container.getAttribute("data-pw-forbidden");
    var forbidden = "";
    if (codes != "") {
        var forbidden_codes = codes.split(" ");
        for (var index = 0; index < forbidden_codes.length; index++) {
            forbidden +=
                String.fromCharCode(parseInt(forbidden_codes[index], 10));
        }
    }
    return {
        field: container.getAttribute("data-pw-field"),
        min: parseInt(container.getAttribute("data-pw-min"), 10),
        max: parseInt(container.getAttribute("data-pw-max"), 10),
        lower: container.getAttribute("data-pw-lower") == "1",
        upper: container.getAttribute("data-pw-upper") == "1",
        digit: container.getAttribute("data-pw-digit") == "1",
        symbol: container.getAttribute("data-pw-symbol") == "1",
        forbidden: forbidden,
        gate: container.getAttribute("data-pw-gate") == "1"
    };
}
/*
 * Turns a list of broken-rule tokens into a short phrase naming just the
 * requirements still missing, taking the wording from the hint's data
 * attributes so it stays in the page's language and minimal.
 */
function passwordMissingText(violations, container)
{
    var attribute_for = {
        too_short: "data-pw-phrase-short",
        lowercase: "data-pw-phrase-lower",
        uppercase: "data-pw-phrase-upper",
        digit: "data-pw-phrase-digit",
        symbol: "data-pw-phrase-symbol",
        forbidden: "data-pw-phrase-forbidden"
    };
    var phrases = [];
    for (var index = 0; index < violations.length; index++) {
        var name = attribute_for[violations[index]];
        if (name) {
            var phrase = container.getAttribute(name);
            if (phrase) {
                phrases.push(phrase);
            }
        }
    }
    return phrases.join(", ");
}
/*
 * Shows the live feedback for one password box. While a rule is still
 * broken it lists, in green, just what is missing (with "Too short" for the
 * length); once every rule is met it instead turns the box's own border
 * green, clears that text, and flashes a brief note at the top of the page;
 * an empty box shows nothing. Because this replaces the hint's sentence,
 * that plain sentence is seen only when JavaScript is off. Returns whether
 * the password is acceptable.
 */
function showPasswordFeedback(container)
{
    var rules = passwordRulesFromContainer(container);
    var field = elt(rules.field);
    var message = container.getElementsByClassName(
        "password-requirements-text")[0];
    if (!field || !message) {
        return true;
    }
    if (field.value == "") {
        message.innerHTML = "";
        setClass(rules.field, false, "field-ok");
        field.password_ok = false;
        return false;
    }
    var violations = passwordPolicyViolations(field.value, rules);
    if (violations.length == 0) {
        message.innerHTML = "";
        setClass(rules.field, true, "field-ok");
        if (!field.password_ok) {
            field.password_ok = true;
            doMessage("<h1 class=\"green\">" +
                container.getAttribute("data-pw-ok") + "</h1>",
                success_message_time);
        }
        return true;
    }
    setClass(rules.field, false, "field-ok");
    field.password_ok = false;
    var missing = passwordMissingText(violations, container);
    missing = missing.charAt(0).toUpperCase() + missing.slice(1);
    message.innerHTML = "<span class=\"green\">" + missing + "</span>";
    return false;
}
/*
 * Wires one password-requirements hint to its password box: shows the live
 * feedback as the person types and, when the hint gates its form, blocks
 * submitting an unacceptable password.
 */
function wirePasswordRequirements(container)
{
    var rules = passwordRulesFromContainer(container);
    var field = elt(rules.field);
    if (!field) {
        return;
    }
    showPasswordFeedback(container);
    listen(field, "keyup", function () {
        showPasswordFeedback(container);
    });
    listen(field, "blur", function () {
        setClass(rules.field, false, "field-ok");
    });
    if (rules.gate && field.form) {
        listen(field.form, "submit", function (event) {
            if (passwordPolicyViolations(field.value, rules).length > 0) {
                showPasswordFeedback(container);
                field.focus();
                event.preventDefault();
            }
        });
    }
}
/*
 * Wires up every password-requirements hint on the page. This runs on all
 * the password forms, not just create-account.
 */
function setupPasswordRequirements()
{
    var containers = document.getElementsByClassName(
        "password-requirements");
    for (var index = 0; index < containers.length; index++) {
        wirePasswordRequirements(containers[index]);
    }
}
/*
 * Whether the create-account e-mail box holds a validly formatted address.
 * Returns a yes/no answer only; the form marks a bad address by outlining
 * the box in red rather than by printing words.
 */
function emailValid()
{
    var mail_format = /^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/;
    return elt("email").value.match(mail_format) != null;
}
/*
 * Outlines the e-mail box in red once the person leaves it having typed
 * something that is not a valid address, and clears that outline once the
 * box is empty or valid again.
 */
function showEmailValidity()
{
    setClass("email",
        elt("email").value != "" && !emailValid(), "field-error");
}
/*
 * Checks the create-account retyped password matches the first one. While
 * they differ it shows a green note saying so; once they match it instead
 * turns the retype box's border green, drops the note, and flashes a brief
 * note at the top of the page.
 */
function passwordMatch()
{
    var retyped_pass = elt("retype-password");
    var password = elt("pass-word");
    var password_match = elt("pass-match");
    if (password.value == "") {
        password_match.innerHTML = "";
        setClass("retype-password", false, "field-ok");
        retyped_pass.passwords_matched = false;
        return true;
    }
    if (retyped_pass.value == password.value) {
        password_match.innerHTML = "";
        setClass("retype-password", true, "field-ok");
        if (!retyped_pass.passwords_matched) {
            retyped_pass.passwords_matched = true;
            doMessage("<h1 class=\"green\">" +
                tl['register_validator_js_retype_password_matched'] +
                "</h1>", success_message_time);
        }
        return true;
    } else {
        setClass("retype-password", false, "field-ok");
        retyped_pass.passwords_matched = false;
        password_match.innerHTML = "<span class=\"green\">" +
            tl['register_validator_js_retype_password_not_matched'] +
            "</span>";
        return false;
    }
}
/*
 * Shows the required-asterisk next to a box when it is empty and hides it
 * otherwise. Returns whether the box has something in it.
 */
function showRequired(field_id)
{
    var field = elt(field_id);
    if (!field) {
        return true;
    }
    if (field.value == "") {
        setDisplay(field_id + "-star", true, "inline");
        return false;
    }
    setDisplay(field_id + "-star", false);
    return true;
}
/*
 * Hides the required-asterisk next to a box once it has something in it,
 * so an asterisk shown on a failed submit clears itself as the person
 * fills the box in.
 */
function hideRequiredIfFilled(field_id)
{
    var field = elt(field_id);
    if (field && field.value != "") {
        setDisplay(field_id + "-star", false);
    }
}
/*
 * Run when the create-account form is submitted: this is the one client
 * check the submit button performs before anything is sent to the server.
 * It shows an asterisk beside every required box left empty, a red outline
 * on a badly typed e-mail, the retype note when the two passwords differ,
 * and the password-rule feedback when the password breaks a rule. Returns
 * whether the form is good to send.
 */
function checkRegistrationForm()
{
    var required = ["firstname", "lastname", "username", "email",
        "pass-word", "retype-password"];
    var good = true;
    for (var index = 0; index < required.length; index++) {
        if (!showRequired(required[index])) {
            good = false;
        }
    }
    if (elt("email").value != "" && !emailValid()) {
        showEmailValidity();
        good = false;
    }
    if (!passwordMatch()) {
        good = false;
    }
    var container = document.getElementsByClassName(
        "password-requirements")[0];
    if (container && !showPasswordFeedback(container)) {
        good = false;
    }
    return good;
}
/*
 * Wires the create-account-only fields. Each required box clears its
 * asterisk as soon as it has something typed in it (and the e-mail box
 * drops its red outline while being edited); the retype box updates its
 * match note live; and submitting with anything missing shows the
 * asterisks and holds the form back. The other password forms do not have
 * these boxes, so this does nothing on them.
 */
function setupRegistrationForm()
{
    if (!elt("firstname")) {
        return;
    }
    var required = ["firstname", "lastname", "username", "email",
        "pass-word", "retype-password"];
    for (var index = 0; index < required.length; index++) {
        wireRequiredField(required[index]);
    }
    listen(elt("email"), "blur", showEmailValidity);
    listen(elt("retype-password"), "keyup", passwordMatch);
    listen(elt("retype-password"), "blur", function () {
        setClass("retype-password", false, "field-ok");
    });
    listen(elt("firstname").form, "submit", function (event) {
        if (!checkRegistrationForm()) {
            event.preventDefault();
        }
    });
}
/*
 * Wires one create-account box so its asterisk clears as the box is filled
 * in; the e-mail box also drops its red outline while being edited.
 */
function wireRequiredField(field_id)
{
    listen(elt(field_id), "keyup", function () {
        hideRequiredIfFilled(field_id);
        if (field_id == "email") {
            setClass("email", false, "field-error");
        }
    });
}
/*
 * On page load, turn on the shared password-requirements feedback for every
 * password box, then wire the create-account-only fields if they are
 * present.
 */
listen(window, "load", function () {
    setupPasswordRequirements();
    setupRegistrationForm();
});
X