From: Mark Otto Date: Mon, 29 Dec 2025 22:47:01 +0000 (-0800) Subject: Password strength plugin (#41980) X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=25f33a86d5c33c4efe25f6d3e1f8d21a728ba301;p=thirdparty%2Fbootstrap.git Password strength plugin (#41980) * feat: add password strength component - Add Strength JavaScript component with customizable scoring - Add SCSS styles for strength meter and bar variants - Add documentation page for password strength - Add unit tests for strength component * Bundle bump * More bundle --- diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index 90851acb4e..f151d30c30 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -26,35 +26,35 @@ }, { "path": "./dist/css/bootstrap.css", - "maxSize": "35.5 kB" + "maxSize": "35.75 kB" }, { "path": "./dist/css/bootstrap.min.css", - "maxSize": "32.0 kB" + "maxSize": "32.25 kB" }, { "path": "./dist/js/bootstrap.bundle.js", - "maxSize": "47.0 kB" + "maxSize": "48.5 kB" }, { "path": "./dist/js/bootstrap.bundle.min.js", - "maxSize": "24.5 kB" + "maxSize": "25.25 kB" }, { "path": "./dist/js/bootstrap.esm.js", - "maxSize": "33.5 kB" + "maxSize": "34.75 kB" }, { "path": "./dist/js/bootstrap.esm.min.js", - "maxSize": "20.0 kB" + "maxSize": "21.0 kB" }, { "path": "./dist/js/bootstrap.js", - "maxSize": "34.0 kB" + "maxSize": "35.25 kB" }, { "path": "./dist/js/bootstrap.min.js", - "maxSize": "18.25 kB" + "maxSize": "19.0 kB" } ], "ci": { diff --git a/js/index.esm.js b/js/index.esm.js index e905721782..1c24edfe3f 100644 --- a/js/index.esm.js +++ b/js/index.esm.js @@ -12,6 +12,7 @@ export { default as Collapse } from './src/collapse.js' export { default as Dialog } from './src/dialog.js' export { default as Dropdown } from './src/dropdown.js' export { default as Offcanvas } from './src/offcanvas.js' +export { default as Strength } from './src/strength.js' export { default as Popover } from './src/popover.js' export { default as ScrollSpy } from './src/scrollspy.js' export { default as Tab } from './src/tab.js' diff --git a/js/index.umd.js b/js/index.umd.js index a2dfece894..eb0dc224af 100644 --- a/js/index.umd.js +++ b/js/index.umd.js @@ -12,6 +12,7 @@ import Collapse from './src/collapse.js' import Dialog from './src/dialog.js' import Dropdown from './src/dropdown.js' import Offcanvas from './src/offcanvas.js' +import Strength from './src/strength.js' import Popover from './src/popover.js' import ScrollSpy from './src/scrollspy.js' import Tab from './src/tab.js' @@ -27,6 +28,7 @@ export default { Dialog, Dropdown, Offcanvas, + Strength, Popover, ScrollSpy, Tab, diff --git a/js/src/strength.js b/js/src/strength.js new file mode 100644 index 0000000000..b46c04f491 --- /dev/null +++ b/js/src/strength.js @@ -0,0 +1,261 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap strength.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import BaseComponent from './base-component.js' +import EventHandler from './dom/event-handler.js' +import SelectorEngine from './dom/selector-engine.js' + +/** + * Constants + */ + +const NAME = 'strength' +const DATA_KEY = 'bs.strength' +const EVENT_KEY = `.${DATA_KEY}` +const DATA_API_KEY = '.data-api' + +const EVENT_STRENGTH_CHANGE = `strengthChange${EVENT_KEY}` + +const SELECTOR_DATA_STRENGTH = '[data-bs-strength]' + +const STRENGTH_LEVELS = ['weak', 'fair', 'good', 'strong'] + +const Default = { + input: null, // Selector or element for password input + minLength: 8, + messages: { + weak: 'Weak', + fair: 'Fair', + good: 'Good', + strong: 'Strong' + }, + weights: { + minLength: 1, + extraLength: 1, + lowercase: 1, + uppercase: 1, + numbers: 1, + special: 1, + multipleSpecial: 1, + longPassword: 1 + }, + thresholds: [2, 4, 6], // weak ≤2, fair ≤4, good ≤6, strong >6 + scorer: null // Custom scoring function (password) => number +} + +const DefaultType = { + input: '(string|element|null)', + minLength: 'number', + messages: 'object', + weights: 'object', + thresholds: 'array', + scorer: '(function|null)' +} + +/** + * Class definition + */ + +class Strength extends BaseComponent { + constructor(element, config) { + super(element, config) + + this._input = this._getInput() + this._segments = SelectorEngine.find('.strength-segment', this._element) + this._textElement = SelectorEngine.findOne('.strength-text', this._element.parentElement) + this._currentStrength = null + + if (this._input) { + this._addEventListeners() + // Check initial value + this._evaluate() + } + } + + // Getters + static get Default() { + return Default + } + + static get DefaultType() { + return DefaultType + } + + static get NAME() { + return NAME + } + + // Public + getStrength() { + return this._currentStrength + } + + evaluate() { + this._evaluate() + } + + // Private + _getInput() { + if (this._config.input) { + return typeof this._config.input === 'string' ? + SelectorEngine.findOne(this._config.input) : + this._config.input + } + + // Look for preceding password input + const parent = this._element.parentElement + return SelectorEngine.findOne('input[type="password"]', parent) + } + + _addEventListeners() { + EventHandler.on(this._input, 'input', () => this._evaluate()) + EventHandler.on(this._input, 'change', () => this._evaluate()) + } + + _evaluate() { + const password = this._input.value + const score = this._calculateScore(password) + const strength = this._scoreToStrength(score) + + if (strength !== this._currentStrength) { + this._currentStrength = strength + this._updateUI(strength, score) + + EventHandler.trigger(this._element, EVENT_STRENGTH_CHANGE, { + strength, + score, + password: password.length > 0 ? '***' : '' // Don't expose actual password + }) + } + } + + _calculateScore(password) { + if (!password) { + return 0 + } + + // Use custom scorer if provided + if (typeof this._config.scorer === 'function') { + return this._config.scorer(password) + } + + const { weights } = this._config + let score = 0 + + // Length scoring + if (password.length >= this._config.minLength) { + score += weights.minLength + } + + if (password.length >= this._config.minLength + 4) { + score += weights.extraLength + } + + // Character variety + if (/[a-z]/.test(password)) { + score += weights.lowercase + } + + if (/[A-Z]/.test(password)) { + score += weights.uppercase + } + + if (/\d/.test(password)) { + score += weights.numbers + } + + // Special characters + if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) { + score += weights.special + } + + // Extra points for more special chars or length + if (/[!@#$%^&*(),.?":{}|<>].*[!@#$%^&*(),.?":{}|<>]/.test(password)) { + score += weights.multipleSpecial + } + + if (password.length >= 16) { + score += weights.longPassword + } + + return score + } + + _scoreToStrength(score) { + if (score === 0) { + return null + } + + const [weak, fair, good] = this._config.thresholds + + if (score <= weak) { + return 'weak' + } + + if (score <= fair) { + return 'fair' + } + + if (score <= good) { + return 'good' + } + + return 'strong' + } + + _updateUI(strength) { + // Update data attribute on element + if (strength) { + this._element.dataset.bsStrength = strength + } else { + delete this._element.dataset.bsStrength + } + + // Update segmented meter + const strengthIndex = strength ? STRENGTH_LEVELS.indexOf(strength) : -1 + + for (const [index, segment] of this._segments.entries()) { + if (index <= strengthIndex) { + segment.classList.add('active') + } else { + segment.classList.remove('active') + } + } + + // Update text feedback + if (this._textElement) { + if (strength && this._config.messages[strength]) { + this._textElement.textContent = this._config.messages[strength] + this._textElement.dataset.bsStrength = strength + + // Also set the color via inheriting from parent or using CSS variable + const colorMap = { + weak: 'danger', + fair: 'warning', + good: 'info', + strong: 'success' + } + this._textElement.style.setProperty('--strength-color', `var(--${colorMap[strength]}-text)`) + } else { + this._textElement.textContent = '' + delete this._textElement.dataset.bsStrength + } + } + } +} + +/** + * Data API implementation + */ + +EventHandler.on(document, `DOMContentLoaded${EVENT_KEY}${DATA_API_KEY}`, () => { + for (const element of SelectorEngine.find(SELECTOR_DATA_STRENGTH)) { + Strength.getOrCreateInstance(element) + } +}) + +export default Strength diff --git a/js/tests/unit/strength.spec.js b/js/tests/unit/strength.spec.js new file mode 100644 index 0000000000..c79a389848 --- /dev/null +++ b/js/tests/unit/strength.spec.js @@ -0,0 +1,467 @@ +import Strength from '../../src/strength.js' +import { clearFixture, createEvent, getFixture } from '../helpers/fixture.js' + +describe('Strength', () => { + let fixtureEl + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + }) + + const getStrengthHtml = (options = {}) => { + const { segments = 4, withText = false, inputId = 'password' } = options + const segmentHtml = Array.from({ length: segments }) + .map(() => '
') + .join('') + const textHtml = withText ? '' : '' + + return ` +
+ +
+ ${segmentHtml} +
+ ${textHtml} +
+ ` + } + + const getStrengthBarHtml = (inputId = 'password') => { + return ` +
+ +
+
+ ` + } + + describe('VERSION', () => { + it('should return plugin version', () => { + expect(Strength.VERSION).toEqual(jasmine.any(String)) + }) + }) + + describe('DATA_KEY', () => { + it('should return plugin data key', () => { + expect(Strength.DATA_KEY).toEqual('bs.strength') + }) + }) + + describe('Default', () => { + it('should return default config', () => { + expect(Strength.Default).toEqual(jasmine.any(Object)) + expect(Strength.Default.minLength).toEqual(8) + expect(Strength.Default.thresholds).toEqual([2, 4, 6]) + }) + }) + + describe('DefaultType', () => { + it('should return default type config', () => { + expect(Strength.DefaultType).toEqual(jasmine.any(Object)) + }) + }) + + describe('constructor', () => { + it('should take care of element either passed as a CSS selector or DOM element', () => { + fixtureEl.innerHTML = getStrengthHtml() + + const strengthEl = fixtureEl.querySelector('.strength') + const strengthBySelector = new Strength('.strength') + const strengthByElement = new Strength(strengthEl) + + expect(strengthBySelector._element).toEqual(strengthEl) + expect(strengthByElement._element).toEqual(strengthEl) + }) + + it('should find password input in parent container', () => { + fixtureEl.innerHTML = getStrengthHtml() + + const strengthEl = fixtureEl.querySelector('.strength') + const passwordInput = fixtureEl.querySelector('input[type="password"]') + const strength = new Strength(strengthEl) + + expect(strength._input).toEqual(passwordInput) + }) + + it('should use custom input selector', () => { + fixtureEl.innerHTML = ` +
+ +
+
+
+
+
+
+
+ ` + + const strengthEl = fixtureEl.querySelector('.strength') + const otherInput = fixtureEl.querySelector('#other-password') + const strength = new Strength(strengthEl, { input: '#other-password' }) + + expect(strength._input).toEqual(otherInput) + }) + }) + + describe('getStrength', () => { + it('should return null initially', () => { + fixtureEl.innerHTML = getStrengthHtml() + + const strengthEl = fixtureEl.querySelector('.strength') + const strength = new Strength(strengthEl) + + expect(strength.getStrength()).toBeNull() + }) + + it('should return current strength level', () => { + fixtureEl.innerHTML = getStrengthHtml() + + const strengthEl = fixtureEl.querySelector('.strength') + const passwordInput = fixtureEl.querySelector('input[type="password"]') + const strength = new Strength(strengthEl) + + // Weak password - just meets min length + passwordInput.value = 'password' + passwordInput.dispatchEvent(createEvent('input')) + + expect(strength.getStrength()).toEqual('weak') + }) + }) + + describe('evaluate', () => { + it('should manually trigger evaluation', () => { + fixtureEl.innerHTML = getStrengthHtml() + + const strengthEl = fixtureEl.querySelector('.strength') + const passwordInput = fixtureEl.querySelector('input[type="password"]') + const strength = new Strength(strengthEl) + + passwordInput.value = 'password123' + strength.evaluate() + + expect(strength.getStrength()).not.toBeNull() + }) + }) + + describe('scoring', () => { + it('should return weak for short passwords meeting min length', () => { + fixtureEl.innerHTML = getStrengthHtml() + + const strengthEl = fixtureEl.querySelector('.strength') + const passwordInput = fixtureEl.querySelector('input[type="password"]') + const strength = new Strength(strengthEl) + + passwordInput.value = 'password' // 8 chars, lowercase only = score 2 + passwordInput.dispatchEvent(createEvent('input')) + + expect(strength.getStrength()).toEqual('weak') + }) + + it('should return fair for passwords with more variety', () => { + fixtureEl.innerHTML = getStrengthHtml() + + const strengthEl = fixtureEl.querySelector('.strength') + const passwordInput = fixtureEl.querySelector('input[type="password"]') + const strength = new Strength(strengthEl) + + passwordInput.value = 'Password1' // 9 chars, lower+upper+number = score 4 + passwordInput.dispatchEvent(createEvent('input')) + + expect(strength.getStrength()).toEqual('fair') + }) + + it('should return good for strong passwords', () => { + fixtureEl.innerHTML = getStrengthHtml() + + const strengthEl = fixtureEl.querySelector('.strength') + const passwordInput = fixtureEl.querySelector('input[type="password"]') + const strength = new Strength(strengthEl) + + passwordInput.value = 'Password1!' // lower+upper+number+special+length = score 5-6 + passwordInput.dispatchEvent(createEvent('input')) + + expect(strength.getStrength()).toEqual('good') + }) + + it('should return strong for very strong passwords', () => { + fixtureEl.innerHTML = getStrengthHtml() + + const strengthEl = fixtureEl.querySelector('.strength') + const passwordInput = fixtureEl.querySelector('input[type="password"]') + const strength = new Strength(strengthEl) + + // Long password with all criteria + passwordInput.value = 'MyStr0ngP@ssword!!' + passwordInput.dispatchEvent(createEvent('input')) + + expect(strength.getStrength()).toEqual('strong') + }) + + it('should return null for empty password', () => { + fixtureEl.innerHTML = getStrengthHtml() + + const strengthEl = fixtureEl.querySelector('.strength') + const passwordInput = fixtureEl.querySelector('input[type="password"]') + const strength = new Strength(strengthEl) + + passwordInput.value = '' + passwordInput.dispatchEvent(createEvent('input')) + + expect(strength.getStrength()).toBeNull() + }) + }) + + describe('custom scorer', () => { + it('should use custom scoring function', () => { + fixtureEl.innerHTML = getStrengthHtml() + + const strengthEl = fixtureEl.querySelector('.strength') + const passwordInput = fixtureEl.querySelector('input[type="password"]') + const customScorer = password => password.length >= 10 ? 8 : 1 + const strength = new Strength(strengthEl, { scorer: customScorer }) + + passwordInput.value = 'short' + passwordInput.dispatchEvent(createEvent('input')) + expect(strength.getStrength()).toEqual('weak') + + passwordInput.value = 'longenough' + passwordInput.dispatchEvent(createEvent('input')) + expect(strength.getStrength()).toEqual('strong') + }) + }) + + describe('custom thresholds', () => { + it('should use custom threshold values', () => { + fixtureEl.innerHTML = getStrengthHtml() + + const strengthEl = fixtureEl.querySelector('.strength') + const passwordInput = fixtureEl.querySelector('input[type="password"]') + const strength = new Strength(strengthEl, { thresholds: [1, 2, 3] }) + + // With low thresholds, even weak passwords rate higher + passwordInput.value = 'password' + passwordInput.dispatchEvent(createEvent('input')) + + // lowercase + minLength = 2, which is now "fair" with [1,2,3] + expect(strength.getStrength()).toEqual('fair') + }) + }) + + describe('custom minLength', () => { + it('should use custom minimum length', () => { + fixtureEl.innerHTML = getStrengthHtml() + + const strengthEl = fixtureEl.querySelector('.strength') + const passwordInput = fixtureEl.querySelector('input[type="password"]') + const strength = new Strength(strengthEl, { minLength: 12 }) + + passwordInput.value = 'password' // Only 8 chars, doesn't meet min + passwordInput.dispatchEvent(createEvent('input')) + + // Should be weak since it doesn't meet minLength requirement + expect(strength.getStrength()).toEqual('weak') + }) + }) + + describe('UI updates', () => { + it('should set data-bs-strength attribute', () => { + fixtureEl.innerHTML = getStrengthHtml() + + const strengthEl = fixtureEl.querySelector('.strength') + const passwordInput = fixtureEl.querySelector('input[type="password"]') + new Strength(strengthEl) // eslint-disable-line no-new + + passwordInput.value = 'password' + passwordInput.dispatchEvent(createEvent('input')) + + expect(strengthEl.dataset.bsStrength).toEqual('weak') + }) + + it('should remove data-bs-strength attribute when empty', () => { + fixtureEl.innerHTML = getStrengthHtml() + + const strengthEl = fixtureEl.querySelector('.strength') + const passwordInput = fixtureEl.querySelector('input[type="password"]') + new Strength(strengthEl) // eslint-disable-line no-new + + passwordInput.value = 'password' + passwordInput.dispatchEvent(createEvent('input')) + + expect(strengthEl.dataset.bsStrength).toEqual('weak') + + passwordInput.value = '' + passwordInput.dispatchEvent(createEvent('input')) + + expect(strengthEl.dataset.bsStrength).toBeUndefined() + }) + + it('should add active class to appropriate segments', () => { + fixtureEl.innerHTML = getStrengthHtml() + + const strengthEl = fixtureEl.querySelector('.strength') + const passwordInput = fixtureEl.querySelector('input[type="password"]') + const segments = strengthEl.querySelectorAll('.strength-segment') + new Strength(strengthEl) // eslint-disable-line no-new + + // Weak - 1 segment active + passwordInput.value = 'password' + passwordInput.dispatchEvent(createEvent('input')) + + expect(segments[0]).toHaveClass('active') + expect(segments[1]).not.toHaveClass('active') + expect(segments[2]).not.toHaveClass('active') + expect(segments[3]).not.toHaveClass('active') + }) + + it('should update text element with message', () => { + fixtureEl.innerHTML = getStrengthHtml({ withText: true }) + + const strengthEl = fixtureEl.querySelector('.strength') + const passwordInput = fixtureEl.querySelector('input[type="password"]') + const textEl = fixtureEl.querySelector('.strength-text') + new Strength(strengthEl) // eslint-disable-line no-new + + passwordInput.value = 'password' + passwordInput.dispatchEvent(createEvent('input')) + + expect(textEl.textContent).toEqual('Weak') + }) + + it('should use custom messages', () => { + fixtureEl.innerHTML = getStrengthHtml({ withText: true }) + + const strengthEl = fixtureEl.querySelector('.strength') + const passwordInput = fixtureEl.querySelector('input[type="password"]') + const textEl = fixtureEl.querySelector('.strength-text') + new Strength(strengthEl, { // eslint-disable-line no-new + messages: { + weak: 'Too weak!', + fair: 'Getting better', + good: 'Nice!', + strong: 'Excellent!' + } + }) + + passwordInput.value = 'password' + passwordInput.dispatchEvent(createEvent('input')) + + expect(textEl.textContent).toEqual('Too weak!') + }) + }) + + describe('events', () => { + it('should trigger strengthChange event', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = getStrengthHtml() + + const strengthEl = fixtureEl.querySelector('.strength') + const passwordInput = fixtureEl.querySelector('input[type="password"]') + new Strength(strengthEl) // eslint-disable-line no-new + + strengthEl.addEventListener('strengthChange.bs.strength', event => { + expect(event.strength).toEqual('weak') + expect(event.score).toBeGreaterThan(0) + resolve() + }) + + passwordInput.value = 'password' + passwordInput.dispatchEvent(createEvent('input')) + }) + }) + + it('should not expose actual password in event', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = getStrengthHtml() + + const strengthEl = fixtureEl.querySelector('.strength') + const passwordInput = fixtureEl.querySelector('input[type="password"]') + new Strength(strengthEl) // eslint-disable-line no-new + + strengthEl.addEventListener('strengthChange.bs.strength', event => { + expect(event.password).toEqual('***') + resolve() + }) + + passwordInput.value = 'password' + passwordInput.dispatchEvent(createEvent('input')) + }) + }) + }) + + describe('strength-bar variant', () => { + it('should work with strength-bar element', () => { + fixtureEl.innerHTML = getStrengthBarHtml() + + const strengthEl = fixtureEl.querySelector('.strength-bar') + const passwordInput = fixtureEl.querySelector('input[type="password"]') + const strength = new Strength(strengthEl) + + passwordInput.value = 'password' + passwordInput.dispatchEvent(createEvent('input')) + + expect(strength.getStrength()).toEqual('weak') + expect(strengthEl.dataset.bsStrength).toEqual('weak') + }) + }) + + describe('dispose', () => { + it('should dispose the instance', () => { + fixtureEl.innerHTML = getStrengthHtml() + + const strengthEl = fixtureEl.querySelector('.strength') + const strength = new Strength(strengthEl) + + expect(Strength.getInstance(strengthEl)).not.toBeNull() + + strength.dispose() + + expect(Strength.getInstance(strengthEl)).toBeNull() + }) + }) + + describe('getInstance', () => { + it('should return strength instance', () => { + fixtureEl.innerHTML = getStrengthHtml() + + const strengthEl = fixtureEl.querySelector('.strength') + const strength = new Strength(strengthEl) + + expect(Strength.getInstance(strengthEl)).toEqual(strength) + expect(Strength.getInstance(strengthEl)).toBeInstanceOf(Strength) + }) + + it('should return null when there is no instance', () => { + fixtureEl.innerHTML = '
' + + const div = fixtureEl.querySelector('div') + + expect(Strength.getInstance(div)).toBeNull() + }) + }) + + describe('getOrCreateInstance', () => { + it('should return existing instance', () => { + fixtureEl.innerHTML = getStrengthHtml() + + const strengthEl = fixtureEl.querySelector('.strength') + const strength = new Strength(strengthEl) + + expect(Strength.getOrCreateInstance(strengthEl)).toEqual(strength) + expect(Strength.getOrCreateInstance(strengthEl)).toBeInstanceOf(Strength) + }) + + it('should create new instance when none exists', () => { + fixtureEl.innerHTML = getStrengthHtml() + + const strengthEl = fixtureEl.querySelector('.strength') + + expect(Strength.getInstance(strengthEl)).toBeNull() + expect(Strength.getOrCreateInstance(strengthEl)).toBeInstanceOf(Strength) + }) + }) +}) diff --git a/scss/forms/_strength.scss b/scss/forms/_strength.scss new file mode 100644 index 0000000000..a3c262fdf6 --- /dev/null +++ b/scss/forms/_strength.scss @@ -0,0 +1,126 @@ +@use "../config" as *; +@use "../variables" as *; +@use "../mixins/border-radius" as *; +@use "../mixins/transition" as *; +@use "form-variables" as *; + +// scss-docs-start strength-variables +$strength-height: .375rem !default; +$strength-gap: .25rem !default; +$strength-margin-top: .25rem !default; +$strength-border-radius: var(--border-radius-pill) !default; +$strength-bg: var(--bg-2) !default; +$strength-transition: background-color .2s ease-in-out, width .3s ease-in-out !default; + +$strength-weak-color: var(--danger-bg) !default; +$strength-fair-color: var(--warning-bg) !default; +$strength-good-color: var(--info-bg) !default; +$strength-strong-color: var(--success-bg) !default; +// scss-docs-end strength-variables + +@layer forms { + // Strength meter container + .strength { + --strength-height: #{$strength-height}; + --strength-gap: #{$strength-gap}; + --strength-bg: #{$strength-bg}; + --strength-border-radius: #{$strength-border-radius}; + --strength-color: #{$strength-bg}; + + display: flex; + gap: var(--strength-gap); + width: 100%; + margin-top: $strength-margin-top; + } + + // Individual strength segments + .strength-segment { + flex: 1; + height: var(--strength-height); + background-color: var(--strength-bg); + @include border-radius(var(--strength-border-radius)); + @include transition($strength-transition); + + // Filled state + &.active { + background-color: var(--strength-color); + } + } + + // Strength levels - set the color variable + .strength[data-bs-strength="weak"] { + --strength-color: #{$strength-weak-color}; + } + + .strength[data-bs-strength="fair"] { + --strength-color: #{$strength-fair-color}; + } + + .strength[data-bs-strength="good"] { + --strength-color: #{$strength-good-color}; + } + + .strength[data-bs-strength="strong"] { + --strength-color: #{$strength-strong-color}; + } + + // Optional text feedback + .strength-text { + display: block; + margin-top: $strength-margin-top; + font-size: $small-font-size; + color: var(--strength-color, var(--secondary-color)); + @include transition(color .2s ease-in-out); + + // Hide when empty + &:empty { + display: none; + } + } + + // Alternative: Single bar variant (like a progress bar) + .strength-bar { + --strength-height: #{$strength-height}; + --strength-bg: #{$strength-bg}; + --strength-border-radius: #{$strength-border-radius}; + --strength-color: transparent; + --strength-width: 0%; + + width: 100%; + height: var(--strength-height); + margin-top: $strength-margin-top; + overflow: hidden; + background-color: var(--strength-bg); + @include border-radius(var(--strength-border-radius)); + + &::after { + display: block; + width: var(--strength-width); + height: 100%; + content: ""; + background-color: var(--strength-color); + @include border-radius(var(--strength-border-radius)); + @include transition($strength-transition); + } + + &[data-bs-strength="weak"] { + --strength-color: #{$strength-weak-color}; + --strength-width: 25%; + } + + &[data-bs-strength="fair"] { + --strength-color: #{$strength-fair-color}; + --strength-width: 50%; + } + + &[data-bs-strength="good"] { + --strength-color: #{$strength-good-color}; + --strength-width: 75%; + } + + &[data-bs-strength="strong"] { + --strength-color: #{$strength-strong-color}; + --strength-width: 100%; + } + } +} diff --git a/scss/forms/index.scss b/scss/forms/index.scss index 0798f2003b..5eddb3027a 100644 --- a/scss/forms/index.scss +++ b/scss/forms/index.scss @@ -7,4 +7,5 @@ @forward "form-range"; @forward "floating-labels"; @forward "input-group"; +@forward "strength"; @forward "validation"; diff --git a/site/data/sidebar.yml b/site/data/sidebar.yml index d448507eb7..8da00f9799 100644 --- a/site/data/sidebar.yml +++ b/site/data/sidebar.yml @@ -73,6 +73,7 @@ - title: Range - title: Input group - title: Floating labels + - title: Password strength - title: Layout - title: Validation diff --git a/site/src/content/docs/forms/password-strength.mdx b/site/src/content/docs/forms/password-strength.mdx new file mode 100644 index 0000000000..7f562435b8 --- /dev/null +++ b/site/src/content/docs/forms/password-strength.mdx @@ -0,0 +1,291 @@ +--- +title: Password strength +description: Provide visual feedback on password strength with segmented meters or progress bars that update as users type. +toc: true +--- + +## Overview + +Strength meters help users create secure passwords by providing real-time feedback on password complexity. Bootstrap's strength component offers: + +- **Segmented meter**: Four segments that fill based on strength level +- **Progress bar variant**: A single bar that grows with strength +- **Text feedback**: Optional text messages for each strength level +- **Customizable scoring**: Configure minimum length and scoring criteria +- **Minimal JavaScript**: Lightweight component following Bootstrap patterns + +## Examples + +### Segmented + +The default strength meter uses four segments that fill progressively as password strength increases. Add `data-bs-strength` to enable the JavaScript behavior. + + + + +
+
+
+
+
+
+ `} /> + +### With text feedback + +Add a `.strength-text` element to display strength messages. + + + + +
+
+
+
+
+
+ + `} /> + +### Progress bar + +Use `.strength-bar` for a single progress bar that grows with password strength. + + + + +
+ `} /> + +### Static segmented + +You can use the component without JavaScript for static displays by setting the `data-bs-strength` attribute manually. + + +
Weak
+
+
+
+
+
+
+ +
+
Fair
+
+
+
+
+
+
+
+
+
Good
+
+
+
+
+
+
+
+
+
Strong
+
+
+
+
+
+
+
`} /> + +### Static progress bar + + +
Weak
+
+ +
+
Fair
+
+
+
+
Good
+
+
+
+
Strong
+
+
`} /> + +## Strength criteria + +The default scoring algorithm evaluates passwords based on: + + +| Criteria | Points | +| --- | --- | +| Meets minimum length (8 characters) | +1 | +| 4+ characters over minimum | +1 | +| Contains lowercase letters | +1 | +| Contains uppercase letters | +1 | +| Contains numbers | +1 | +| Contains special characters | +1 | +| Multiple special characters | +1 | +| 16+ characters | +1 | + + + +| Level | Points | +| --- | --- | +| Weak | 1-2 | +| Fair | 3-4 | +| Good | 5-6 | +| Strong | 7-8 | + + +## Usage + +### Via data attributes + +Add `data-bs-strength` to automatically initialize. The component will look for a password input in the same parent container. + +```html +
+ +
+
+
+
+
+
+
+``` + +### Via JavaScript + +```js +const element = document.querySelector('.strength') +const strength = new bootstrap.Strength(element, { + input: '#my-password-input', + minLength: 10, + messages: { + weak: 'Too weak', + fair: 'Could be better', + good: 'Good password', + strong: 'Excellent!' + } +}) +``` + +### Options + + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `input` | string \| element | `null` | Selector or element for the password input. If not provided, looks for `input[type="password"]` in the parent. | +| `minLength` | number | `8` | Minimum password length for first strength point. | +| `messages` | object | `{weak, fair, good, strong}` | Custom messages for each strength level. | +| `weights` | object | See below | Point values for each scoring criterion. Set to `0` to disable a criterion. | +| `thresholds` | array | `[2, 4, 6]` | Score boundaries for strength levels: `[weak, fair, good]`. Scores above the last value are "strong". | +| `scorer` | function | `null` | Custom scoring function `(password) => number`. Overrides built-in scoring when provided. | + + +#### Default weights + +```js +{ + minLength: 1, // Meets minimum length + extraLength: 1, // 4+ characters over minimum + lowercase: 1, // Contains lowercase letters + uppercase: 1, // Contains uppercase letters + numbers: 1, // Contains numbers + special: 1, // Contains special characters + multipleSpecial: 1, // Multiple special characters + longPassword: 1 // 16+ characters +} +``` + +#### Custom weights + +```js +// Disable uppercase requirement, make special chars worth more +new bootstrap.Strength(element, { + weights: { + uppercase: 0, // Don't require uppercase + special: 2 // Special chars worth 2 points + }, + thresholds: [3, 5, 7] // Adjust thresholds for new max score +}) +``` + +#### Custom scorer + +```js +// Fully custom scoring logic +new bootstrap.Strength(element, { + scorer: (password) => { + let score = 0 + if (password.length >= 12) score += 4 + if (/[!@#$%]/.test(password)) score += 4 + return score + }, + thresholds: [2, 4, 6] +}) +``` + +### Methods + +| Method | Description | +| --- | --- | +| `getStrength()` | Returns the current strength level (`'weak'`, `'fair'`, `'good'`, `'strong'`, or `null`). | +| `evaluate()` | Manually trigger a password evaluation. | +| `dispose()` | Destroys the component instance. | + +```js +const element = document.querySelector('.strength') +const strength = bootstrap.Strength.getOrCreateInstance(element) + +// Get current strength +console.log(strength.getStrength()) // 'fair' +``` + +### Events + +| Event | Description | +| --- | --- | +| `strengthChange.bs.strength` | Fired when the strength level changes. Includes `strength` and `score` properties. | + +```js +const element = document.querySelector('.strength') + +element.addEventListener('strengthChange.bs.strength', event => { + console.log('Strength:', event.strength) // 'weak', 'fair', 'good', 'strong' + console.log('Score:', event.score) // 0-8 +}) +``` + +## CSS-only usage + +For simple cases or server-rendered content, you can use the component without JavaScript by manually setting the `data-bs-strength` attribute and `.active` classes. + +```html + +
+
+
+
+
+
+``` + +## Accessibility + +- The strength meter is decorative feedback and should be used alongside clear password requirements text +- Consider using `aria-describedby` to associate the password input with requirement text +- Screen reader users benefit from the text feedback option which announces strength changes + +## CSS + +### Sass variables + +