• File: themestrap.plugin.auth.js
  • Full Path: /home/daemondevin/public_html/blog/assets/admin/fa/themestrap.plugin.auth.js
  • Date Modified: 10/17/2026 7:00 PM
  • File size: 51.1 KB
  • MIME-type: application/javascript
  • Charset: utf-8
/**
 * Themestrap Auth
 *
 * Progressive-enhancement layer for authentication forms, independent of any backend.
 * It adds inline validation, password show/hide, a strength meter, a Caps Lock hint, busy
 * and lockout states, inline or toast feedback, and optional AJAX submission. The network
 * layer is a pluggable adapter, so the same form can talk to MODX, a PHP session, Laravel,
 * Django, a JWT REST API, Firebase, an OAuth provider, or anything else.
 *
 * Without JavaScript the form still submits natively, so the page keeps working.
 *
 * Markup (unchanged from earlier versions):
 *
 *   <form data-plugin-auth
 *         data-auth-action="login"
 *         data-plugin-options='{"adapter": "json", "redirect": "/dashboard"}'
 *         method="post" action="/login">
 *
 *     <div data-auth-message></div>                       <!-- optional inline status area -->
 *
 *     <div data-auth-field="username">
 *       <label for="u">Username</label>
 *       <input id="u" name="username" required data-auth-error>
 *       <div data-auth-feedback></div>
 *     </div>
 *
 *     <div data-auth-field="password">
 *       <label for="p">Password</label>
 *       <input id="p" name="password" type="password" required minlength="8"
 *              data-auth-strength data-auth-error>
 *       <button type="button" data-auth-toggle-password>Show</button>
 *       <div data-auth-strength-meter></div>
 *       <span data-auth-capslock class="d-none">Caps Lock is on</span>
 *       <div data-auth-feedback></div>
 *     </div>
 *
 *     <button type="submit" data-auth-submit>
 *       <span data-auth-submit-label>Sign in</span>
 *       <span data-auth-spinner class="d-none"></span>
 *     </button>
 *   </form>
 *
 * Optional extras:
 *   data-auth-step="credentials|otp|..."   a section shown for one step of a multi-step flow
 *   data-auth-provider="github"            a button that starts an OAuth or social sign-in
 *   data-auth-match="#password"            field must equal another field
 *   data-auth-validate="name"              run options.validators[name]
 *   data-auth-redirect, data-auth-success-msg, data-auth-error-msg   per-form overrides
 *
 * Adapters (options.adapter):
 *   'form'  (default)  native browser submit
 *   'json'             fetch, response understood in many common shapes
 *   'modx'             alias of 'json' with form-encoded bodies (Login and FormIt style)
 *   'rest'             alias of 'json' with JSON bodies
 *   object             { submit(ctx) => Promise<result> } for any other system
 *   function           shorthand for { submit: fn }
 *   ajax: true is shorthand for adapter 'json'.
 *
 * A result is { ok, message, errors, redirect, token, challenge, retryAfter, data }.
 *
 * Public API (via stored instance):
 *   const auth = $('#form').data('__pluginAuth');
 *   auth.validate();             // true | false
 *   auth.submit();               // validate and send, same as pressing the button
 *   auth.setBusy(true|false);
 *   auth.setMessage(text, 'error' | 'success' | 'info');
 *   auth.clearMessage();
 *   auth.setErrors({ field: 'message' });
 *   auth.showStep('otp');        // reveal a [data-auth-step] section
 *   auth.getToken(); auth.setToken(t); auth.clearToken();
 *   auth.reset();
 *   auth.destroy();
 *
 *   themestrap.PluginAuth.registerAdapter(name, adapter);
 *   themestrap.PluginAuth.getToken(key, store);
 *   themestrap.PluginAuth.authHeaders(tokenOptions);   // { Authorization: 'Bearer …' } for your own calls
 *
 * Events. Earlier jQuery events are kept, and every event is also a native CustomEvent
 * (bubbling, dispatched on the form) named ts.auth.*:
 *   auth:validate  / ts.auth.validate    (instance, isValid)
 *   auth:submit    / ts.auth.submit      (instance, data)
 *   auth:success   / ts.auth.success     (instance, result)
 *   auth:error     / ts.auth.error       (instance, result)
 *   auth:challenge / ts.auth.challenge   (instance, step)
 *   auth:token     / ts.auth.token       (instance, token)
 *   ts.auth.redirect                     cancelable, detail.url
 *
 * CSS custom properties (all optional): --ts-au-invalid, --ts-au-valid, --ts-au-muted,
 * --ts-au-warn-bg, --ts-au-warn-text, --ts-au-info-bg, --ts-au-info-text
 *
 * Init.js wiring (DOM ready, auth forms must work without scrolling):
 *   if (typeof $.fn['themestrapPluginAuth'] === 'function' && $('[data-plugin-auth]').length) {
 *       $(() => {
 *           $('[data-plugin-auth]:not(.manual)').each(function () {
 *               const $this = $(this);
 *               const opts = themestrap.fn.getOptions($this.data('plugin-options')) || undefined;
 *               $this.themestrapPluginAuth(opts);
 *           });
 *       });
 *   }
 */
// Auth
(((themestrap = {}, $) => {
    const instanceName = '__pluginAuth';
    const STYLE_ID     = 'ts-auth-styles';
    const EVENT_NS     = 'auth';

    let instanceCount = 0;

    const LIGHT_TOKENS = `
        --au-invalid:   var(--ts-au-invalid, #dc3545);
        --au-valid:     var(--ts-au-valid, #198754);
        --au-muted:     var(--ts-au-muted, #6c757d);
        --au-track:     var(--ts-au-track, #e9ecef);
        --au-warn-bg:   var(--ts-au-warn-bg, #fff3cd);
        --au-warn-text: var(--ts-au-warn-text, #664d03);
        --au-info-bg:   var(--ts-au-info-bg, #e7f1ff);
        --au-info-text: var(--ts-au-info-text, #0a3c7d);
    `;
    const DARK_TOKENS = `
        --au-invalid:   var(--ts-au-invalid, #f87171);
        --au-valid:     var(--ts-au-valid, #4ade80);
        --au-muted:     var(--ts-au-muted, #9fb3c8);
        --au-track:     var(--ts-au-track, rgba(255,255,255,.12));
        --au-warn-bg:   var(--ts-au-warn-bg, rgba(251,191,36,.15));
        --au-warn-text: var(--ts-au-warn-text, #fbbf24);
        --au-info-bg:   var(--ts-au-info-bg, rgba(96,165,250,.15));
        --au-info-text: var(--ts-au-info-text, #93c5fd);
    `;

    // Rules use :where() where Bootstrap supplies the same thing, so the framework always wins
    // and the form still looks right when Bootstrap is not on the page.
    const CSS_TEXT = `
        .auth-form { ${LIGHT_TOKENS} }
        @media (prefers-color-scheme: dark) {
            .auth-form { ${DARK_TOKENS} }
        }
        html.dark .auth-form,
        [data-bs-theme="dark"] .auth-form { ${DARK_TOKENS} }
        [data-bs-theme="light"] .auth-form { ${LIGHT_TOKENS} }

        :where(.auth-form) .d-none { display: none !important; }
        .auth-form [hidden] { display: none !important; }
        .auth-form [data-auth-feedback]:empty { display: none; }
        .auth-form [data-auth-feedback]:not(:empty) { display: block; margin-top: .25rem; font-size: .85rem; color: var(--au-invalid); }
        :where(.auth-form) .is-invalid { border-color: var(--au-invalid); }
        :where(.auth-form) .is-valid   { border-color: var(--au-valid); }
        :where(.auth-form) .spinner-border {
            display: inline-block; width: 1em; height: 1em; vertical-align: -.125em;
            border: .15em solid currentColor; border-right-color: transparent; border-radius: 50%;
            animation: ts-auth-spin .6s linear infinite;
        }
        @keyframes ts-auth-spin { to { transform: rotate(360deg); } }
        @media (prefers-reduced-motion: reduce) { :where(.auth-form) .spinner-border { animation-duration: 2s; } }

        .auth-message { margin: 0 0 1rem; padding: .6rem .85rem; border-radius: 8px; font-size: .9rem; border: 1px solid transparent; }
        .auth-message:empty { display: none; }
        .auth-message--error   { background: color-mix(in srgb, var(--au-invalid) 12%, transparent); border-color: var(--au-invalid); }
        .auth-message--success { background: color-mix(in srgb, var(--au-valid) 12%, transparent);   border-color: var(--au-valid); }
        .auth-message--info    { background: var(--au-info-bg); color: var(--au-info-text); border-color: transparent; }

        .auth-strength { display: flex; align-items: center; gap: 4px; margin-top: 6px; height: 22px; }
        .auth-strength .auth-strength-bar { flex: 1; height: 4px; border-radius: 2px; background: var(--au-track); transition: background-color .2s ease; }
        .auth-strength[data-score="0"] .auth-strength-bar--on,
        .auth-strength[data-score="1"] .auth-strength-bar--on { background: #dc3545; }
        .auth-strength[data-score="2"] .auth-strength-bar--on { background: #f0ad00; }
        .auth-strength[data-score="3"] .auth-strength-bar--on { background: #0dcaf0; }
        .auth-strength[data-score="4"] .auth-strength-bar--on { background: #198754; }
        .auth-strength .auth-strength-label { margin-left: 8px; font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--au-muted); min-width: 64px; }

        .auth-capslock { display: inline-block; margin-top: 6px; padding: 2px 8px; border-radius: 10px; background: var(--au-warn-bg); color: var(--au-warn-text); font-size: 11px; font-weight: 600; letter-spacing: .03em; }
        .auth-capslock::before { content: "\\21EA "; margin-right: 4px; }

        .auth-profile-card { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border: 1px solid var(--au-track); border-radius: 8px; margin-bottom: 1rem; }
        @media (prefers-reduced-motion: reduce) { .auth-strength .auth-strength-bar { transition: none; } }
    `;

    function injectStyles() {
        if (document.getElementById(STYLE_ID)) return;
        const style = document.createElement('style');
        style.id = STYLE_ID;
        style.textContent = CSS_TEXT;
        (document.head || document.documentElement).appendChild(style);
    }

    // Helpers

    const STRENGTH_LABELS  = ['Too weak', 'Weak', 'Fair', 'Good', 'Strong'];
    const STRENGTH_CLASSES = ['bg-danger', 'bg-danger', 'bg-warning', 'bg-info', 'bg-success'];
    const EMAIL_RX         = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

    function scorePassword(pw) {
        if (!pw) return 0;
        let score = 0;
        if (pw.length >= 8)  score++;
        if (pw.length >= 12) score++;
        if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++;
        if (/\d/.test(pw))   score++;
        if (/[^A-Za-z0-9]/.test(pw)) score++;
        return Math.min(score, 4);
    }

    const isObj  = (v) => v && typeof v === 'object' && !Array.isArray(v);
    const truthy = (v) => v === true || v === 1 || v === '1' || String(v).toLowerCase() === 'true';

    function getPath(obj, path) {
        if (!obj || path == null) return undefined;
        return String(path).split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
    }

    function firstOf(obj, paths) {
        for (let i = 0; i < paths.length; i++) {
            const v = getPath(obj, paths[i]);
            if (v !== undefined && v !== null && v !== '') return v;
        }
        return undefined;
    }

    function readCookie(name) {
        const m = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)'));
        return m ? decodeURIComponent(m[1]) : '';
    }

    function storeFor(kind) {
        try {
            if (kind === 'local')   return window.localStorage;
            if (kind === 'session') return window.sessionStorage;
        } catch (e) { /* storage blocked */ }
        return null;
    }

    const memoryTokens = {};

    // Redirects stay on this site unless the page opts in, so a forged response cannot send users away.
    function safeRedirect(url, allowExternal) {
        if (!url) return '';
        try {
            const u = new URL(url, window.location.href);
            if (!/^https?:$/.test(u.protocol)) return '';
            if (!allowExternal && u.origin !== window.location.origin) return '';
            return u.href;
        } catch (e) {
            return '';
        }
    }

    // Turns any common server reply into { ok, message, errors, redirect, token, challenge, retryAfter, data }
    function defaultNormalize(body, res, opts) {
        const b   = isObj(body) ? body : {};
        const map = opts.response || {};
        const pick = (key, paths) => (map[key] !== undefined
            ? (typeof map[key] === 'function' ? map[key](body, res) : getPath(b, map[key]))
            : firstOf(b, paths));

        let ok = !!res.ok;
        ['success', 'ok', 'authenticated'].forEach((k) => {
            if (k in b) ok = ok && truthy(b[k]);
        });
        if (typeof b.status === 'string' && /^(error|fail|failed|failure)$/i.test(b.status)) ok = false;
        if (typeof b.error === 'string' && b.error) ok = false;
        if (map.success !== undefined) {
            const s = typeof map.success === 'function' ? map.success(body, res) : getPath(b, map.success);
            ok = !!res.ok && truthy(s);
        }

        let message = pick('message', ['message', 'msg', 'detail', 'error_description', 'error', 'title']);
        if (message && typeof message !== 'string') message = '';

        // Field errors: { errors: { field: 'msg' | ['msg'] } }, [{ field, message }], or DRF style { field: ['msg'] }
        let errors = pick('errors', ['errors', 'fieldErrors', 'field_errors']);
        const out = {};
        if (isObj(errors)) {
            Object.keys(errors).forEach((k) => {
                const v = errors[k];
                out[k] = Array.isArray(v) ? String(v[0]) : (isObj(v) ? String(v.message || '') : String(v));
            });
        } else if (Array.isArray(errors)) {
            errors.forEach((e) => {
                const f = e && (e.field || e.name || e.path || e.param);
                if (f) out[f] = String(e.message || e.msg || '');
            });
        } else if (!res.ok && !map.errors) {
            Object.keys(b).forEach((k) => {
                if (Array.isArray(b[k]) && b[k].length && typeof b[k][0] === 'string') out[k] = b[k][0];
            });
        }

        let challenge = pick('challenge', ['challenge', 'next_step', 'nextStep', 'requires']);
        if (!challenge && (truthy(b.mfa_required) || truthy(b.mfa) || truthy(b.requires_otp) || truthy(b.otp_required))) challenge = 'otp';
        if (challenge && typeof challenge !== 'string') challenge = 'otp';

        let retry = pick('retryAfter', ['retryAfter', 'retry_after']);
        const header = res.headers && typeof res.headers.get === 'function' ? res.headers.get('Retry-After') : '';
        if ((retry === undefined || retry === '') && header) retry = header;

        const token = pick('token', ['token', 'access_token', 'accessToken', 'jwt', 'data.token', 'data.access_token']);
        const redirect = pick('redirect', ['redirect', 'redirectUrl', 'redirect_url', 'redirect_to', 'next', 'location'])
            || (res.headers && typeof res.headers.get === 'function' ? res.headers.get('Location') : '');

        return {
            ok, message: message || '', errors: out, redirect: redirect || '', token: token ? String(token) : '',
            challenge: challenge || '', retryAfter: Math.max(0, parseInt(retry, 10) || 0), data: body
        };
    }

    // Built in adapters

    function jsonTransport(ctx) {
        const o = ctx.options;
        const headers = $.extend({ 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }, o.headers);
        let body;

        if (o.encoding === 'json') {
            headers['Content-Type'] = 'application/json';
            body = JSON.stringify(ctx.data);
        } else {
            const p = new URLSearchParams();
            Object.keys(ctx.data).forEach((k) => {
                const v = ctx.data[k];
                (Array.isArray(v) ? v : [v]).forEach((x) => p.append(k, x == null ? '' : x));
            });
            body = p.toString();
            headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
        }

        if (ctx.csrf && ctx.csrf.value && ctx.csrf.header) headers[ctx.csrf.header] = ctx.csrf.value;
        const bearer = ctx.instance._bearer();
        if (bearer) headers[bearer.name] = bearer.value;

        const method = ctx.method === 'GET' ? 'GET' : ctx.method;
        let url = ctx.url;
        const init = { method, headers, credentials: o.credentials };
        if (method === 'GET') {
            url += (url.indexOf('?') === -1 ? '?' : '&') + body;
            delete headers['Content-Type'];
        } else {
            init.body = body;
        }

        const useFetch = typeof window.fetch === 'function';
        const finish = (res, text) => {
            let parsed = null;
            let isJson = true;
            if (text) {
                try { parsed = JSON.parse(text); } catch (e) { isJson = false; }
            }
            if (!isJson && res.status !== 204) {
                return { ok: false, message: o.unexpectedResponseMessage, errors: {}, redirect: '', token: '', challenge: '', retryAfter: 0, data: text };
            }
            const normalize = typeof o.normalize === 'function' ? o.normalize : defaultNormalize;
            const result = normalize(parsed, res, o, ctx);
            return result;
        };

        if (useFetch) {
            let timer = null;
            if (typeof AbortController === 'function' && o.timeout) {
                const ac = new AbortController();
                init.signal = ac.signal;
                timer = setTimeout(() => ac.abort(), o.timeout);
            }
            return window.fetch(url, init)
                .then((res) => res.text().then((text) => { clearTimeout(timer); return finish(res, text); }))
                .catch((err) => { clearTimeout(timer); throw err; });
        }

        return new Promise((resolve, reject) => {
            $.ajax({ url, method, headers, data: init.body, processData: false, contentType: headers['Content-Type'], dataType: 'text', timeout: o.timeout })
                .done((text, st, xhr) => resolve(finish({ ok: true, status: xhr.status, headers: { get: (n) => xhr.getResponseHeader(n) } }, text)))
                .fail((xhr) => {
                    if (!xhr.status) return reject(new Error('network'));
                    resolve(finish({ ok: false, status: xhr.status, headers: { get: (n) => xhr.getResponseHeader(n) } }, xhr.responseText));
                });
        });
    }

    const ADAPTERS = {
        form: { native: true },
        json: { submit: jsonTransport },
        modx: { encoding: 'form', submit: jsonTransport },
        rest: { encoding: 'json', submit: jsonTransport }
    };

    class PluginAuth {
        constructor($el, opts) {
            return this.initialize($el, opts);
        }

        initialize($el, opts) {
            if ($el.data(instanceName)) {
                return this;
            }

            this.$el    = $el;
            this.action = ($el.data('auth-action') || 'login').toString().toLowerCase();
            this.busy   = false;
            this._uid   = 'auth-' + Math.random().toString(36).slice(2, 8);

            this
                .setData()
                .setOptions(opts)
                .build()
                .events();

            return this;
        }

        setData() {
            this.$el.data(instanceName, this);
            return this;
        }

        setOptions(opts) {
            this.options = $.extend(true, {}, PluginAuth.defaults, opts, {
                wrapper: this.$el
            });
            // Functions and adapter objects are kept as given, not cloned
            if (opts) {
                ['normalize', 'beforeSubmit', 'transform'].forEach((k) => { if (typeof opts[k] === 'function') this.options[k] = opts[k]; });
                if (opts.adapter && typeof opts.adapter !== 'string') this.options.adapter = opts.adapter;
                if (opts.validators) this.options.validators = opts.validators;
                if (opts.providers)  this.options.providers  = opts.providers;
                if (opts.response)   this.options.response   = opts.response;
            }
            this.adapter = this._resolveAdapter();
            if (this.adapter.encoding && !(opts && opts.encoding)) this.options.encoding = this.adapter.encoding;
            return this;
        }

        _resolveAdapter() {
            let a = this.options.adapter;
            if (a == null || a === '') a = this.options.ajax ? 'json' : 'form';
            if (typeof a === 'function') return { submit: a };
            if (isObj(a)) return a;
            return ADAPTERS[a] || ADAPTERS.form;
        }

        build() {
            const self = this;
            const $el  = self.$el;

            instanceCount++;
            injectStyles();

            self.$fields  = $el.find('[data-auth-field]');
            self.$inputs  = $el.find('[data-auth-error]');
            self.$submit  = $el.find('[data-auth-submit]').first();
            self.$label   = self.$submit.find('[data-auth-submit-label]').first();
            self.$spinner = self.$submit.find('[data-auth-spinner]').first();

            if (self.$label.length) {
                self.originalLabel = self.$label.text();
            }

            $el.addClass('auth-form auth-form-' + self.action);
            if ($el.is('form')) $el.attr('novalidate', 'novalidate');

            // Link each input to its feedback text so screen readers announce the error
            self.$inputs.each(function () {
                const $i = $(this);
                const $fb = $i.closest('[data-auth-field]').find('[data-auth-feedback]').first();
                if ($fb.length) {
                    if (!$fb.attr('id')) $fb.attr('id', self._uid + '-fb-' + ($i.attr('name') || Math.random().toString(36).slice(2, 6)));
                    $i.attr('aria-describedby', $fb.attr('id'));
                }
            });

            // Hide every step except the first
            const $steps = $el.find('[data-auth-step]');
            if ($steps.length) {
                self._step = $steps.first().data('auth-step');
                $steps.each(function (i) { if (i > 0) this.setAttribute('hidden', ''); });
            }

            return this;
        }

        events() {
            const self = this;
            const $el  = self.$el;
            const ns   = '.' + self._uid;

            // A button or link marked as the logout action
            if (!$el.is('form')) {
                $el.on('click' + ns, (e) => {
                    if (self.action !== 'logout') return;
                    e.preventDefault();
                    self.submit();
                });
            }

            $el.on('submit' + ns, function (e) {
                if (self._nativeSubmit) { self._nativeSubmit = false; return; }

                if (!self.validate()) {
                    e.preventDefault();
                    e.stopImmediatePropagation();
                    self._message(self.options.errorMessage || $el.data('auth-error-msg') || 'Please correct the highlighted fields.', 'error');
                    return false;
                }

                e.preventDefault();
                const original = e.originalEvent || e;
                self._submitter = original && original.submitter ? original.submitter : null;
                self._dispatchSubmit();
                return false;
            });

            // Live validation on blur / change
            self.$inputs.on('blur' + ns + ' change' + ns, function () {
                self._validateField($(this));
            });

            // Re-validate on input once a field has been marked invalid
            self.$inputs.on('input' + ns, function () {
                const $i = $(this);
                if ($i.hasClass(self.options.classes.invalid)) {
                    self._validateField($i);
                }
            });

            // Password strength meter
            $el.find('[data-auth-strength]').on('input' + ns, function () {
                self._updateStrength($(this));
            });

            // Password toggle
            $el.on('click' + ns, '[data-auth-toggle-password]', function (e) {
                e.preventDefault();
                self._togglePassword($(this));
            });

            // Social and OAuth buttons
            $el.on('click' + ns, '[data-auth-provider]', function (e) {
                e.preventDefault();
                self.provider($(this).data('auth-provider'));
            });

            // Caps Lock warning
            $el.find('input[type="password"]').on('keyup' + ns, function (e) {
                const caps = e.originalEvent && typeof e.originalEvent.getModifierState === 'function'
                    ? e.originalEvent.getModifierState('CapsLock')
                    : false;
                $(this).closest('[data-auth-field]').find('[data-auth-capslock]').toggleClass(self.options.classes.hidden, !caps);
            });

            return this;
        }

        // Public API

        validate() {
            const self = this;
            let valid = true;
            let first = null;

            self.$inputs.each(function () {
                if (!self._active($(this))) return;
                if (!self._validateField($(this))) {
                    valid = false;
                    if (!first) first = this;
                }
            });

            if (first && typeof first.focus === 'function') first.focus();
            self._emit('validate', [self, valid], { instance: self, valid });
            return valid;
        }

        // Validates and sends, exactly like pressing the submit button
        submit() {
            if (!this.validate()) {
                this._message(this.options.errorMessage || 'Please correct the highlighted fields.', 'error');
                return this;
            }
            this._submitter = null;
            this._dispatchSubmit();
            return this;
        }

        reset() {
            const self = this;
            self.$inputs
                .removeClass(self.options.classes.invalid + ' ' + self.options.classes.valid)
                .removeAttr('aria-invalid')
                .closest('[data-auth-field]')
                .find('[data-auth-feedback]').text('');
            self.clearMessage();
            return self;
        }

        setBusy(busy) {
            const self = this;
            self.busy = !!busy;

            if (!self.$submit.length) return self;

            self.$submit.prop('disabled', self.busy);
            self.$submit.attr('aria-busy', self.busy ? 'true' : 'false');

            if (self.$spinner.length) {
                self.$spinner.toggleClass(self.options.classes.hidden, !self.busy);
            }
            if (self.$label.length) {
                self.$label.text(self.busy ? (self.options.busyLabel || 'Working...') : self.originalLabel);
            }
            return self;
        }

        setMessage(text, type) {
            this._message(text, type || 'info', true);
            return this;
        }

        clearMessage() {
            const $m = this._messageEl(false);
            if ($m && $m.length) $m.text('').removeClass('auth-message--error auth-message--success auth-message--info').removeAttr('role');
            return this;
        }

        setErrors(map) {
            const self = this;
            Object.keys(map || {}).forEach((name) => {
                const $i = self.$el.find('[name="' + name + '"]').first();
                if (!$i.length) return;
                const $fb = $i.closest('[data-auth-field]').find('[data-auth-feedback]').first();
                $i.removeClass(self.options.classes.valid).addClass(self.options.classes.invalid).attr('aria-invalid', 'true');
                if ($fb.length) $fb.text(map[name]);
            });
            return self;
        }

        // Reveals one [data-auth-step] section and hides the others
        showStep(name) {
            const self = this;
            const $steps = self.$el.find('[data-auth-step]');
            // An unknown name changes nothing, so a server asking for a step the page lacks cannot hide the form
            if (!$steps.filter(function () { return $(this).data('auth-step') === name; }).length) return self;
            $steps.each(function () {
                if ($(this).data('auth-step') === name) this.removeAttribute('hidden');
                else this.setAttribute('hidden', '');
            });
            self._step = name;
            const $first = $steps.filter(function () { return $(this).data('auth-step') === name; }).find('input, select, textarea').not('[type=hidden]').first();
            if ($first.length) $first.trigger('focus');
            return self;
        }

        getStep() { return this._step || ''; }

        // Tokens, for APIs that answer a sign in with a token instead of a cookie
        getToken() { return PluginAuth.getToken(this.options.token.key, this.options.token.store); }

        setToken(token) {
            const t = this.options.token;
            const kind = t.store;
            if (!kind) return this;
            if (kind === 'memory') {
                memoryTokens[t.key] = token;
            } else {
                const s = storeFor(kind);
                if (s) { try { s.setItem(t.key, token); } catch (e) { /* quota or blocked */ } }
            }
            this._emit('token', [this, token], { instance: this, token });
            return this;
        }

        clearToken() {
            const t = this.options.token;
            delete memoryTokens[t.key];
            ['local', 'session'].forEach((k) => { const s = storeFor(k); if (s) { try { s.removeItem(t.key); } catch (e) { /* blocked */ } } });
            return this;
        }

        _bearer() {
            const t = this.options.token;
            if (!t || !t.store || !t.header) return null;
            const v = this.getToken();
            return v ? { name: t.header, value: (t.scheme ? t.scheme + ' ' : '') + v } : null;
        }

        // Starts an OAuth or social sign in configured under options.providers
        provider(name) {
            const self = this;
            const p = self.options.providers[name];
            if (!p || !p.url) return self;

            if (p.mode !== 'popup') {
                window.location.href = p.url;
                return self;
            }

            const w = p.width || 480, h = p.height || 640;
            const left = Math.max(0, (window.screenX || 0) + ((window.outerWidth || w) - w) / 2);
            const top  = Math.max(0, (window.screenY || 0) + ((window.outerHeight || h) - h) / 2);
            const win = window.open(p.url, 'ts-auth-' + name, 'width=' + w + ',height=' + h + ',left=' + left + ',top=' + top);
            if (!win) {
                self._message(self.options.popupBlockedMessage, 'error');
                return self;
            }
            self.setBusy(true);

            const origin = p.origin || window.location.origin;
            const onMessage = (e) => {
                if (e.origin !== origin) return;
                const d = e.data || {};
                if (d.type !== 'ts-auth') return;
                window.removeEventListener('message', onMessage);
                clearInterval(poll);
                self.setBusy(false);
                const result = { ok: !!d.ok, message: d.message || '', errors: {}, redirect: d.redirect || '', token: d.token || '', challenge: '', retryAfter: 0, data: d };
                self._handleResult(result);
            };
            const poll = setInterval(() => {
                if (win.closed) {
                    clearInterval(poll);
                    window.removeEventListener('message', onMessage);
                    self.setBusy(false);
                }
            }, 500);
            window.addEventListener('message', onMessage);
            return self;
        }

        destroy() {
            const self = this;
            const ns   = '.' + self._uid;

            clearInterval(self._lockTimer);
            self.$el.off(ns);
            self.$inputs.off(ns);
            self.$el.find('[data-auth-strength]').off(ns);
            self.$el.find('input[type="password"]').off(ns);
            self.$el.removeClass('auth-form auth-form-' + self.action);
            self.$el.removeData(instanceName);

            instanceCount--;
            if (instanceCount <= 0) {
                instanceCount = 0;
                const styleEl = document.getElementById(STYLE_ID);
                if (styleEl) styleEl.remove();
            }
            return self;
        }

        // Internals

        _active($input) {
            return !$input.closest('[data-auth-step][hidden]').length;
        }

        _emit(name, jqArgs, detail) {
            this.$el.trigger('auth:' + name, jqArgs);
            const el = this.$el[0];
            if (el && typeof el.dispatchEvent === 'function') {
                el.dispatchEvent(new CustomEvent('ts.' + EVENT_NS + '.' + name, { detail: detail || {}, bubbles: true }));
            }
        }

        _messageEl(create) {
            const self = this;
            let $m = self.$el.find(self.options.messageTarget).first();
            if (!$m.length && create) {
                $m = $('<div class="auth-message" data-auth-message></div>').prependTo(self.$el);
            }
            return $m;
        }

        _hasToasts() {
            return !!(themestrap && themestrap.PluginToast && typeof themestrap.PluginToast.show === 'function');
        }

        _message(text, type, force) {
            if (!text) return;
            const self = this;
            const inline = force || !self._hasToasts() || self.$el.find(self.options.messageTarget).length;

            if (inline) {
                const $m = self._messageEl(true);
                $m.removeClass('auth-message--error auth-message--success auth-message--info')
                    .addClass('auth-message auth-message--' + type)
                    .attr('role', type === 'error' ? 'alert' : 'status')
                    .text(text);
            }
            if (!force && self.options.toasts && self._hasToasts()) {
                themestrap.PluginToast.show({
                    title: type === 'success' ? self.options.toastSuccessTitle : (type === 'info' ? '' : self.options.toastErrorTitle),
                    body:  text,
                    type:  type === 'success' ? 'success' : (type === 'info' ? 'info' : 'danger')
                });
            }
        }

        _validateField($input) {
            const self     = this;
            const o        = self.options;
            const $field   = $input.closest('[data-auth-field]');
            const $fb      = $field.find('[data-auth-feedback]').first();
            const val      = ($input.val() || '').toString();
            const type     = ($input.attr('type') || 'text').toLowerCase();
            const required = $input.prop('required');

            let error = '';

            if (required && !val.trim()) {
                error = o.messages.required;
            }
            else if (val && type === 'email' && !EMAIL_RX.test(val)) {
                error = o.messages.email;
            }
            else if (val && $input.attr('minlength')) {
                const min = parseInt($input.attr('minlength'), 10);
                if (val.length < min) error = o.messages.minlength.replace('%s', min);
            }
            else if (val && $input.attr('pattern')) {
                try {
                    if (!new RegExp('^(?:' + $input.attr('pattern') + ')$').test(val)) error = o.messages.pattern;
                } catch (e) { /* malformed pattern, skip */ }
            }
            if (!error && val && $input.data('auth-match')) {
                const $other = self.$el.find($input.data('auth-match'));
                if ($other.length && val !== ($other.val() || '').toString()) error = o.messages.match;
            }
            if (!error && val && $input.data('auth-validate')) {
                const fn = o.validators && o.validators[$input.data('auth-validate')];
                if (typeof fn === 'function') {
                    const r = fn(val, $input, self);
                    if (typeof r === 'string' && r) error = r;
                    else if (r === false) error = o.messages.pattern;
                }
            }

            const c = o.classes;
            if (error) {
                $input.removeClass(c.valid).addClass(c.invalid).attr('aria-invalid', 'true');
                if ($fb.length) $fb.text(error);
                return false;
            }

            $input.removeClass(c.invalid).removeAttr('aria-invalid');
            if (val.trim()) $input.addClass(c.valid);
            if ($fb.length) $fb.text('');
            return true;
        }

        _updateStrength($input) {
            const val    = ($input.val() || '').toString();
            const score  = scorePassword(val);
            const $field = $input.closest('[data-auth-field]');
            const $meter = $field.find('[data-auth-strength-meter]').first();

            if (!$meter.length) return;

            if (!$meter.children().length) {
                const bars = [0, 1, 2, 3, 4].map(() => '<span class="auth-strength-bar"></span>').join('');
                $meter.addClass('auth-strength').html(bars + '<span class="auth-strength-label"></span>');
            }

            $meter.attr('data-score', val.length ? score : '');
            $meter.children('.auth-strength-bar').each(function (i) {
                const on = i <= score && val.length > 0;
                $(this)
                    .removeClass('bg-danger bg-warning bg-info bg-success')
                    .toggleClass(STRENGTH_CLASSES[score], on)
                    .toggleClass('auth-strength-bar--on', on);
            });
            $meter.children('.auth-strength-label').text(val.length ? STRENGTH_LABELS[score] : '');
        }

        _togglePassword($btn) {
            const $field = $btn.closest('[data-auth-field]');
            const $pw    = $field.find('input').filter(function () {
                const t = ($(this).attr('type') || '').toLowerCase();
                return t === 'password' || t === 'text';
            }).filter('[data-auth-strength], [name*="password"]').first();

            if (!$pw.length) return;

            const isHidden = $pw.attr('type') === 'password';
            $pw.attr('type', isHidden ? 'text' : 'password');
            $btn.text(isHidden ? (this.options.hidePasswordLabel || 'Hide') : (this.options.showPasswordLabel || 'Show'));
            $btn.attr('aria-pressed', isHidden ? 'true' : 'false');
        }

        _collect() {
            const self = this;
            const data = {};
            self.$el.find('input, select, textarea').each(function () {
                const $i = $(this);
                const name = $i.attr('name');
                if (!name) return;
                const type = ($i.attr('type') || '').toLowerCase();
                if ((type === 'checkbox' || type === 'radio') && !$i.prop('checked')) return;
                if (type === 'file') return;
                data[name] = $i.val();
            });
            // The pressed button counts, as it does in a native submit
            const s = self._submitter;
            if (s && s.name && !(s.name in data)) data[s.name] = s.value;
            return data;
        }

        _csrf() {
            const c = this.options.csrf;
            if (c === false) return null;
            const cfg = isObj(c) ? c : {};
            let value = typeof cfg.token === 'function' ? cfg.token() : cfg.token;
            let header = cfg.header || '';

            if (!value) {
                const fieldSel = cfg.field
                    ? 'input[name="' + cfg.field + '"]'
                    : 'input[name="_csrf"], input[name="csrf_token"], input[name="csrfmiddlewaretoken"], input[name="authenticity_token"], input[name="_token"], input[name="__RequestVerificationToken"]';
                const $f = this.$el.find(fieldSel).first();
                if ($f.length) value = $f.val();
            }
            if (!value) {
                const $m = $('meta[name="csrf-token"], meta[name="csrf"], meta[name="_csrf"]').first();
                if ($m.length) value = $m.attr('content');
            }
            if (!value) {
                const cookie = readCookie(cfg.cookie || 'XSRF-TOKEN');
                if (cookie) { value = cookie; header = header || 'X-XSRF-TOKEN'; }
            }
            if (!value) return null;
            return { value, header: header || 'X-CSRF-Token' };
        }

        _endpoint() {
            const o = this.options;
            const $el = this.$el;
            return o.endpoints[this.action] || o.url || ($el.is('form') ? $el.attr('action') : '') || $el.data('auth-url') || window.location.href;
        }

        _method() {
            const o = this.options;
            return (o.method || (this.$el.is('form') ? this.$el.attr('method') : '') || 'POST').toUpperCase();
        }

        // Runs beforeSubmit, then either lets the browser post the form or sends through the adapter
        _dispatchSubmit() {
            const self = this;
            const o    = self.options;
            let data   = self._collect();

            self._emit('submit', [self, data], { instance: self, data });
            self.clearMessage();

            const go = (final) => {
                if (final === false) return;
                data = final || data;
                if (!self.adapter || self.adapter.native || typeof self.adapter.submit !== 'function') {
                    self._nativePost();
                    return;
                }
                self._send(data);
            };

            if (typeof o.beforeSubmit === 'function') {
                self.setBusy(true);
                Promise.resolve()
                    .then(() => o.beforeSubmit.call(self, data, { action: self.action, instance: self }))
                    .then((r) => { if (r === false) self.setBusy(false); go(r); })
                    .catch(() => { self.setBusy(false); self._message(o.networkErrorMessage, 'error'); });
                return;
            }
            go();
        }

        _nativePost() {
            const self = this;
            const form = self.$el[0];
            if (!self.$el.is('form')) return;
            self.setBusy(true);
            self._nativeSubmit = true;
            const s = self._submitter;
            if (typeof form.requestSubmit === 'function') {
                try { form.requestSubmit(s || undefined); return; } catch (e) { /* fall through */ }
            }
            // Older browsers: carry the pressed button's name and value across
            if (s && s.name) {
                const h = document.createElement('input');
                h.type = 'hidden'; h.name = s.name; h.value = s.value;
                form.appendChild(h);
            }
            self._nativeSubmit = false;
            form.submit();
        }

        _send(data) {
            const self = this;
            const o    = self.options;
            const payload = typeof o.transform === 'function' ? (o.transform(data, { action: self.action }) || data) : data;
            const ctx = {
                action: self.action, data: payload, url: self._endpoint(), method: self._method(),
                form: self.$el.is('form') ? self.$el[0] : null, options: o, instance: self,
                csrf: self._csrf(), submitter: self._submitter
            };

            self.setBusy(true);
            Promise.resolve()
                .then(() => self.adapter.submit(ctx))
                .then((result) => {
                    const r = $.extend({ ok: false, message: '', errors: {}, redirect: '', token: '', challenge: '', retryAfter: 0 }, result);
                    self._handleResult(r);
                })
                .catch((err) => {
                    const detail = { network: true, error: err };
                    self._message(o.networkErrorMessage, 'error');
                    self._emit('error', [self, detail], { instance: self, result: detail });
                    self.setBusy(false);
                });
        }

        _handleResult(r) {
            const self = this;
            const o    = self.options;
            const $el  = self.$el;

            if (r.challenge) {
                self.setBusy(false);
                self.showStep(r.challenge);
                if (r.message) self._message(r.message, 'info');
                self._emit('challenge', [self, r.challenge], { instance: self, step: r.challenge, result: r });
                return;
            }

            if (r.ok) {
                if (r.token) { self.setToken(r.token); }
                const msg = r.message || $el.data('auth-success-msg') || o.successMessage;
                if (msg) self._message(msg, 'success');
                self._emit('success', [self, r], { instance: self, result: r });

                if (self.action === 'logout') self.clearToken();

                const target = safeRedirect(r.redirect || $el.data('auth-redirect') || o.redirect, o.allowExternalRedirect);
                if (target) {
                    const ev = new CustomEvent('ts.' + EVENT_NS + '.redirect', { detail: { url: target, instance: self }, bubbles: true, cancelable: true });
                    const proceed = $el[0].dispatchEvent(ev);
                    if (proceed) setTimeout(() => { window.location.href = target; }, o.redirectDelay);
                    else self.setBusy(false);
                } else {
                    self.setBusy(false);
                }
                return;
            }

            // Failure: paint field errors, then the message
            if (r.errors && Object.keys(r.errors).length) self.setErrors(r.errors);
            self._message(r.message || $el.data('auth-error-msg') || o.errorMessage, 'error');
            self._emit('error', [self, r], { instance: self, result: r });
            self.setBusy(false);
            if (r.retryAfter > 0) self._lockFor(r.retryAfter);
        }

        // Disables the submit button with a countdown, for rate limits and lockouts
        _lockFor(seconds) {
            const self = this;
            if (!self.$submit.length) return;
            clearInterval(self._lockTimer);
            let left = seconds;
            const fmt = (n) => Math.floor(n / 60) + ':' + ('0' + (n % 60)).slice(-2);
            const paint = () => {
                self.$submit.prop('disabled', true);
                if (self.$label.length) self.$label.text(self.options.lockedLabel.replace('%s', fmt(left)));
            };
            paint();
            self._lockTimer = setInterval(() => {
                left--;
                if (left <= 0) {
                    clearInterval(self._lockTimer);
                    self.$submit.prop('disabled', false);
                    if (self.$label.length) self.$label.text(self.originalLabel);
                    self.clearMessage();
                } else {
                    paint();
                }
            }, 1000);
        }
    }

    PluginAuth.defaults = {
        // Transport
        ajax:                false,           // shorthand for adapter: 'json'
        adapter:             null,            // 'form' | 'json' | 'modx' | 'rest' | { submit(ctx) } | function(ctx)
        url:                 '',              // overrides the form action
        endpoints:           {},              // { login: '/api/login', register: '/api/register', ... }
        method:              '',              // overrides the form method
        encoding:            'form',          // 'form' or 'json' request bodies
        headers:             {},              // extra request headers
        credentials:         'same-origin',   // fetch credentials mode
        timeout:             20000,           // ms before a request is aborted, 0 to disable
        csrf:                'auto',          // 'auto' | false | { header, field, token, cookie }

        // Understanding replies
        response:            {},              // { success, message, errors, redirect, token, challenge, retryAfter } as path or function
        normalize:           null,            // function(body, res, options, ctx) returning a result, replaces the default

        // Flow
        beforeSubmit:        null,            // function(data, ctx) returning data, false to cancel, or a Promise of either
        transform:           null,            // function(data, ctx) returning the data to send
        redirect:            '',              // where to go after success
        redirectDelay:       600,             // ms
        allowExternalRedirect: false,         // allow redirects to other sites

        // Tokens for APIs that sign in with a token instead of a cookie
        token: {
            store:  false,                    // false | 'memory' | 'session' | 'local'
            key:    'ts_auth_token',
            header: 'Authorization',
            scheme: 'Bearer'
        },

        // OAuth and social providers: { github: { url, mode: 'redirect' | 'popup', origin, width, height } }
        providers:           {},

        // Feedback
        toasts:              true,            // PluginToast on success and error, when that plugin is loaded
        messageTarget:       '[data-auth-message]',
        toastSuccessTitle:   'Success',
        toastErrorTitle:     'Sorry',
        successMessage:      '',
        errorMessage:        'Please check the form for errors.',
        networkErrorMessage: 'Could not reach the server. Please try again.',
        unexpectedResponseMessage: 'The server sent a reply this form could not understand.',
        popupBlockedMessage: 'The sign in window was blocked. Please allow pop-ups and try again.',

        // UI labels
        busyLabel:           'Working...',
        lockedLabel:         'Try again in %s',
        showPasswordLabel:   'Show',
        hidePasswordLabel:   'Hide',

        // Class names, for frameworks other than Bootstrap
        classes: { invalid: 'is-invalid', valid: 'is-valid', hidden: 'd-none' },

        // Validation
        validators:          {},              // { name: function(value, $input, instance) returning '' or an error }
        messages: {
            required:  'This field is required.',
            email:     'Please enter a valid email address.',
            minlength: 'Must be at least %s characters.',
            pattern:   'Invalid format.',
            match:     'Values do not match.'
        }
    };

    PluginAuth.adapters = ADAPTERS;

    // Add or replace a named adapter: themestrap.PluginAuth.registerAdapter('firebase', { submit(ctx) {} })
    PluginAuth.registerAdapter = function (name, adapter) {
        ADAPTERS[name] = typeof adapter === 'function' ? { submit: adapter } : adapter;
        return PluginAuth;
    };

    PluginAuth.getToken = function (key, store) {
        key = key || PluginAuth.defaults.token.key;
        if (store === 'memory') return memoryTokens[key] || '';
        const kinds = store ? [store] : ['session', 'local'];
        for (let i = 0; i < kinds.length; i++) {
            const s = storeFor(kinds[i]);
            if (s) { try { const v = s.getItem(key); if (v) return v; } catch (e) { /* blocked */ } }
        }
        return memoryTokens[key] || '';
    };

    // Headers for your own API calls once signed in: fetch(url, { headers: PluginAuth.authHeaders() })
    PluginAuth.authHeaders = function (tokenOptions) {
        const t = $.extend({}, PluginAuth.defaults.token, tokenOptions);
        const v = PluginAuth.getToken(t.key, t.store || undefined);
        const h = {};
        if (v) h[t.header] = (t.scheme ? t.scheme + ' ' : '') + v;
        return h;
    };

    $.extend(themestrap, { PluginAuth });

    $.fn.themestrapPluginAuth = function (opts) {
        return this.map(function () {
            const $this = $(this);
            if ($this.data(instanceName)) {
                return $this.data(instanceName);
            }
            return new PluginAuth($this, opts);
        });
    };

})).apply(this, [window.themestrap, jQuery]);