Create New Item
Item Type
File
Folder
Item Name
Search file in folder and subfolders...
Are you sure want to rename?
File Manager
/
portableapps
/
assets
/
admin
/
themestrap
:
themestrap.plugin.auth.js
Advanced Search
Upload
New Item
Settings
Back
Back Up
Advanced Editor
Save
/** * 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]);