Skip to content

Commit eebf75b

Browse files
authored
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
1 parent f0788d5 commit eebf75b

File tree

9 files changed

+1158
-8
lines changed

9 files changed

+1158
-8
lines changed

.bundlewatch.config.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,35 +26,35 @@
2626
},
2727
{
2828
"path": "./dist/css/bootstrap.css",
29-
"maxSize": "35.5 kB"
29+
"maxSize": "35.75 kB"
3030
},
3131
{
3232
"path": "./dist/css/bootstrap.min.css",
33-
"maxSize": "32.0 kB"
33+
"maxSize": "32.25 kB"
3434
},
3535
{
3636
"path": "./dist/js/bootstrap.bundle.js",
37-
"maxSize": "47.0 kB"
37+
"maxSize": "48.5 kB"
3838
},
3939
{
4040
"path": "./dist/js/bootstrap.bundle.min.js",
41-
"maxSize": "24.5 kB"
41+
"maxSize": "25.25 kB"
4242
},
4343
{
4444
"path": "./dist/js/bootstrap.esm.js",
45-
"maxSize": "33.5 kB"
45+
"maxSize": "34.75 kB"
4646
},
4747
{
4848
"path": "./dist/js/bootstrap.esm.min.js",
49-
"maxSize": "20.0 kB"
49+
"maxSize": "21.0 kB"
5050
},
5151
{
5252
"path": "./dist/js/bootstrap.js",
53-
"maxSize": "34.0 kB"
53+
"maxSize": "35.25 kB"
5454
},
5555
{
5656
"path": "./dist/js/bootstrap.min.js",
57-
"maxSize": "18.25 kB"
57+
"maxSize": "19.0 kB"
5858
}
5959
],
6060
"ci": {

js/index.esm.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export { default as Collapse } from './src/collapse.js'
1212
export { default as Dialog } from './src/dialog.js'
1313
export { default as Dropdown } from './src/dropdown.js'
1414
export { default as Offcanvas } from './src/offcanvas.js'
15+
export { default as Strength } from './src/strength.js'
1516
export { default as Popover } from './src/popover.js'
1617
export { default as ScrollSpy } from './src/scrollspy.js'
1718
export { default as Tab } from './src/tab.js'

js/index.umd.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import Collapse from './src/collapse.js'
1212
import Dialog from './src/dialog.js'
1313
import Dropdown from './src/dropdown.js'
1414
import Offcanvas from './src/offcanvas.js'
15+
import Strength from './src/strength.js'
1516
import Popover from './src/popover.js'
1617
import ScrollSpy from './src/scrollspy.js'
1718
import Tab from './src/tab.js'
@@ -27,6 +28,7 @@ export default {
2728
Dialog,
2829
Dropdown,
2930
Offcanvas,
31+
Strength,
3032
Popover,
3133
ScrollSpy,
3234
Tab,

js/src/strength.js

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
/**
2+
* --------------------------------------------------------------------------
3+
* Bootstrap strength.js
4+
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
5+
* --------------------------------------------------------------------------
6+
*/
7+
8+
import BaseComponent from './base-component.js'
9+
import EventHandler from './dom/event-handler.js'
10+
import SelectorEngine from './dom/selector-engine.js'
11+
12+
/**
13+
* Constants
14+
*/
15+
16+
const NAME = 'strength'
17+
const DATA_KEY = 'bs.strength'
18+
const EVENT_KEY = `.${DATA_KEY}`
19+
const DATA_API_KEY = '.data-api'
20+
21+
const EVENT_STRENGTH_CHANGE = `strengthChange${EVENT_KEY}`
22+
23+
const SELECTOR_DATA_STRENGTH = '[data-bs-strength]'
24+
25+
const STRENGTH_LEVELS = ['weak', 'fair', 'good', 'strong']
26+
27+
const Default = {
28+
input: null, // Selector or element for password input
29+
minLength: 8,
30+
messages: {
31+
weak: 'Weak',
32+
fair: 'Fair',
33+
good: 'Good',
34+
strong: 'Strong'
35+
},
36+
weights: {
37+
minLength: 1,
38+
extraLength: 1,
39+
lowercase: 1,
40+
uppercase: 1,
41+
numbers: 1,
42+
special: 1,
43+
multipleSpecial: 1,
44+
longPassword: 1
45+
},
46+
thresholds: [2, 4, 6], // weak ≤2, fair ≤4, good ≤6, strong >6
47+
scorer: null // Custom scoring function (password) => number
48+
}
49+
50+
const DefaultType = {
51+
input: '(string|element|null)',
52+
minLength: 'number',
53+
messages: 'object',
54+
weights: 'object',
55+
thresholds: 'array',
56+
scorer: '(function|null)'
57+
}
58+
59+
/**
60+
* Class definition
61+
*/
62+
63+
class Strength extends BaseComponent {
64+
constructor(element, config) {
65+
super(element, config)
66+
67+
this._input = this._getInput()
68+
this._segments = SelectorEngine.find('.strength-segment', this._element)
69+
this._textElement = SelectorEngine.findOne('.strength-text', this._element.parentElement)
70+
this._currentStrength = null
71+
72+
if (this._input) {
73+
this._addEventListeners()
74+
// Check initial value
75+
this._evaluate()
76+
}
77+
}
78+
79+
// Getters
80+
static get Default() {
81+
return Default
82+
}
83+
84+
static get DefaultType() {
85+
return DefaultType
86+
}
87+
88+
static get NAME() {
89+
return NAME
90+
}
91+
92+
// Public
93+
getStrength() {
94+
return this._currentStrength
95+
}
96+
97+
evaluate() {
98+
this._evaluate()
99+
}
100+
101+
// Private
102+
_getInput() {
103+
if (this._config.input) {
104+
return typeof this._config.input === 'string' ?
105+
SelectorEngine.findOne(this._config.input) :
106+
this._config.input
107+
}
108+
109+
// Look for preceding password input
110+
const parent = this._element.parentElement
111+
return SelectorEngine.findOne('input[type="password"]', parent)
112+
}
113+
114+
_addEventListeners() {
115+
EventHandler.on(this._input, 'input', () => this._evaluate())
116+
EventHandler.on(this._input, 'change', () => this._evaluate())
117+
}
118+
119+
_evaluate() {
120+
const password = this._input.value
121+
const score = this._calculateScore(password)
122+
const strength = this._scoreToStrength(score)
123+
124+
if (strength !== this._currentStrength) {
125+
this._currentStrength = strength
126+
this._updateUI(strength, score)
127+
128+
EventHandler.trigger(this._element, EVENT_STRENGTH_CHANGE, {
129+
strength,
130+
score,
131+
password: password.length > 0 ? '***' : '' // Don't expose actual password
132+
})
133+
}
134+
}
135+
136+
_calculateScore(password) {
137+
if (!password) {
138+
return 0
139+
}
140+
141+
// Use custom scorer if provided
142+
if (typeof this._config.scorer === 'function') {
143+
return this._config.scorer(password)
144+
}
145+
146+
const { weights } = this._config
147+
let score = 0
148+
149+
// Length scoring
150+
if (password.length >= this._config.minLength) {
151+
score += weights.minLength
152+
}
153+
154+
if (password.length >= this._config.minLength + 4) {
155+
score += weights.extraLength
156+
}
157+
158+
// Character variety
159+
if (/[a-z]/.test(password)) {
160+
score += weights.lowercase
161+
}
162+
163+
if (/[A-Z]/.test(password)) {
164+
score += weights.uppercase
165+
}
166+
167+
if (/\d/.test(password)) {
168+
score += weights.numbers
169+
}
170+
171+
// Special characters
172+
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
173+
score += weights.special
174+
}
175+
176+
// Extra points for more special chars or length
177+
if (/[!@#$%^&*(),.?":{}|<>].*[!@#$%^&*(),.?":{}|<>]/.test(password)) {
178+
score += weights.multipleSpecial
179+
}
180+
181+
if (password.length >= 16) {
182+
score += weights.longPassword
183+
}
184+
185+
return score
186+
}
187+
188+
_scoreToStrength(score) {
189+
if (score === 0) {
190+
return null
191+
}
192+
193+
const [weak, fair, good] = this._config.thresholds
194+
195+
if (score <= weak) {
196+
return 'weak'
197+
}
198+
199+
if (score <= fair) {
200+
return 'fair'
201+
}
202+
203+
if (score <= good) {
204+
return 'good'
205+
}
206+
207+
return 'strong'
208+
}
209+
210+
_updateUI(strength) {
211+
// Update data attribute on element
212+
if (strength) {
213+
this._element.dataset.bsStrength = strength
214+
} else {
215+
delete this._element.dataset.bsStrength
216+
}
217+
218+
// Update segmented meter
219+
const strengthIndex = strength ? STRENGTH_LEVELS.indexOf(strength) : -1
220+
221+
for (const [index, segment] of this._segments.entries()) {
222+
if (index <= strengthIndex) {
223+
segment.classList.add('active')
224+
} else {
225+
segment.classList.remove('active')
226+
}
227+
}
228+
229+
// Update text feedback
230+
if (this._textElement) {
231+
if (strength && this._config.messages[strength]) {
232+
this._textElement.textContent = this._config.messages[strength]
233+
this._textElement.dataset.bsStrength = strength
234+
235+
// Also set the color via inheriting from parent or using CSS variable
236+
const colorMap = {
237+
weak: 'danger',
238+
fair: 'warning',
239+
good: 'info',
240+
strong: 'success'
241+
}
242+
this._textElement.style.setProperty('--strength-color', `var(--${colorMap[strength]}-text)`)
243+
} else {
244+
this._textElement.textContent = ''
245+
delete this._textElement.dataset.bsStrength
246+
}
247+
}
248+
}
249+
}
250+
251+
/**
252+
* Data API implementation
253+
*/
254+
255+
EventHandler.on(document, `DOMContentLoaded${EVENT_KEY}${DATA_API_KEY}`, () => {
256+
for (const element of SelectorEngine.find(SELECTOR_DATA_STRENGTH)) {
257+
Strength.getOrCreateInstance(element)
258+
}
259+
})
260+
261+
export default Strength

0 commit comments

Comments
 (0)