stimulus-password-strength 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rubocop.yml +41 -0
- data/AGENTS.md +71 -0
- data/CHANGELOG.md +28 -0
- data/CLAUDE.md +11 -0
- data/LICENSE.txt +21 -0
- data/PUBLISH_CHECKLIST.md +82 -0
- data/README.md +182 -0
- data/Rakefile +8 -0
- data/app/javascript/stimulus_password_strength_controller.js +193 -0
- data/app/views/stimulus_password_strength/_field.html.erb +114 -0
- data/config/importmap.rb +2 -0
- data/config/locales/en.yml +8 -0
- data/config/locales/pl.yml +8 -0
- data/lib/generators/stimulus_password_strength/install/install_generator.rb +55 -0
- data/lib/generators/stimulus_password_strength/install/templates/password_policy.rb.tt +20 -0
- data/lib/generators/stimulus_password_strength/install/templates/stimulus_password_strength.rb.tt +19 -0
- data/lib/stimulus/password/strength.rb +3 -0
- data/lib/stimulus_password_strength/configuration.rb +63 -0
- data/lib/stimulus_password_strength/engine.rb +24 -0
- data/lib/stimulus_password_strength/helper.rb +121 -0
- data/lib/stimulus_password_strength/version.rb +5 -0
- data/lib/stimulus_password_strength.rb +18 -0
- data/vendor/javascript/zxcvbn.js +4 -0
- metadata +88 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a0150f9398507c2a875986766bf55719e1301a68cc66a12591f1796354fc872f
|
|
4
|
+
data.tar.gz: c469c7ec416cff1eb8dcb78f3a0a5cdd961a9d60110a317e1f6d9836727b442d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: dc3c6f68e8ede33665fec285d0079d4cee0020b823b441bc8be08ea25c638862b656746ab5c9b359be1d3e055346c4a639c5ce4d9249c7e073cacb6f0c454189
|
|
7
|
+
data.tar.gz: 8118612aa2c405d37dd6460a72aac666faec1317f8f6b0d98bcbcc6d039201f57a90fed64d36e3f4dcdd2e8fce198dae5abbdbca05ed8031869b19e62a50a420
|
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
AllCops:
|
|
2
|
+
TargetRubyVersion: 3.2
|
|
3
|
+
NewCops: enable
|
|
4
|
+
SuggestExtensions: false
|
|
5
|
+
Exclude:
|
|
6
|
+
- "test/tmp/**/*"
|
|
7
|
+
|
|
8
|
+
Layout/LineLength:
|
|
9
|
+
Max: 150
|
|
10
|
+
|
|
11
|
+
Metrics/AbcSize:
|
|
12
|
+
Enabled: false
|
|
13
|
+
|
|
14
|
+
Metrics/BlockLength:
|
|
15
|
+
Exclude:
|
|
16
|
+
- "test/**/*"
|
|
17
|
+
Max: 30
|
|
18
|
+
|
|
19
|
+
Metrics/CyclomaticComplexity:
|
|
20
|
+
Enabled: false
|
|
21
|
+
|
|
22
|
+
Metrics/MethodLength:
|
|
23
|
+
Enabled: false
|
|
24
|
+
|
|
25
|
+
Metrics/ModuleLength:
|
|
26
|
+
Enabled: false
|
|
27
|
+
|
|
28
|
+
Metrics/PerceivedComplexity:
|
|
29
|
+
Enabled: false
|
|
30
|
+
|
|
31
|
+
Style/Documentation:
|
|
32
|
+
Enabled: false
|
|
33
|
+
|
|
34
|
+
Style/FrozenStringLiteralComment:
|
|
35
|
+
Enabled: false
|
|
36
|
+
|
|
37
|
+
Style/FormatStringToken:
|
|
38
|
+
Enabled: false
|
|
39
|
+
|
|
40
|
+
Style/StringLiterals:
|
|
41
|
+
Enabled: false
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
Use this file when an AI coding agent is integrating `stimulus-password-strength` into a host Rails app.
|
|
4
|
+
|
|
5
|
+
## Goal
|
|
6
|
+
|
|
7
|
+
Treat this gem as a UI layer for password UX, not as password security policy.
|
|
8
|
+
|
|
9
|
+
The integration should:
|
|
10
|
+
|
|
11
|
+
- reduce signup friction
|
|
12
|
+
- keep the meter visible even with 1Password/LastPass overlays
|
|
13
|
+
- preserve backend validation until the host app intentionally migrates it
|
|
14
|
+
|
|
15
|
+
## Recommended Rollout
|
|
16
|
+
|
|
17
|
+
1. Add the gem and run:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
bundle install
|
|
21
|
+
bin/rails generate stimulus_password_strength:install
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
2. Use the generated `PasswordPolicy` as the shared source of truth for backend rules and UI requirements.
|
|
25
|
+
3. Replace the main password field with `password_strength_field`, passing `requirements: PasswordPolicy.requirements`.
|
|
26
|
+
4. Update all user-facing password views. Minimum:
|
|
27
|
+
- signup / registration
|
|
28
|
+
- password reset / change password
|
|
29
|
+
5. If the app has additional password-setting flows, update them too:
|
|
30
|
+
- invitation acceptance
|
|
31
|
+
- onboarding set password
|
|
32
|
+
- admin-created account activation
|
|
33
|
+
- forced password change
|
|
34
|
+
6. Do not remove `password_confirmation` if the host app still requires it.
|
|
35
|
+
7. Do not change backend validation during the first rollout unless validation is already wired to `PasswordPolicy`.
|
|
36
|
+
8. Roll out the UI first, then decide separately whether to simplify backend policy.
|
|
37
|
+
|
|
38
|
+
## Host App Checks
|
|
39
|
+
|
|
40
|
+
Before changing any auth flow, verify:
|
|
41
|
+
|
|
42
|
+
1. Does the app require `password_confirmation`?
|
|
43
|
+
2. Does the app enforce regex rules such as uppercase/lowercase/digit?
|
|
44
|
+
3. Are there custom validation messages that would drift from the new UI?
|
|
45
|
+
4. Are there additional password flows outside signup and reset?
|
|
46
|
+
5. Does the host app use a custom design system that should override helper classes?
|
|
47
|
+
|
|
48
|
+
## What Not to Change Automatically
|
|
49
|
+
|
|
50
|
+
1. Do not remove `password_confirmation` without reviewing validations, tests, and reset flow behavior.
|
|
51
|
+
2. Do not change password policy just because the meter shows `good` or `strong`.
|
|
52
|
+
3. Do not define `requirements` separately from `PasswordPolicy`.
|
|
53
|
+
4. Do not hardcode hints such as `Must contain uppercase...` unless the backend actually enforces them.
|
|
54
|
+
5. Do not assume Tailwind classes applied dynamically from JS will work in every host app.
|
|
55
|
+
|
|
56
|
+
## Minimal Smoke Test
|
|
57
|
+
|
|
58
|
+
1. Signup: weak password -> meter, toggle, and backend validation all behave correctly.
|
|
59
|
+
2. Signup with 1Password/LastPass enabled: meter and requirements remain visible.
|
|
60
|
+
3. Password reset: the same component behaves consistently in the second flow.
|
|
61
|
+
4. Browser autofill: meter and requirements refresh after typing or autofill.
|
|
62
|
+
5. Mobile viewport: toggle does not overlap text or password manager icons.
|
|
63
|
+
|
|
64
|
+
## Policy Reminder
|
|
65
|
+
|
|
66
|
+
This gem does not replace:
|
|
67
|
+
|
|
68
|
+
- model validation
|
|
69
|
+
- rate limiting
|
|
70
|
+
- anti-abuse controls
|
|
71
|
+
- password reset security
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.2
|
|
4
|
+
|
|
5
|
+
- switched gem packaging to `git ls-files`
|
|
6
|
+
- added a lightweight RuboCop setup for the gem repository
|
|
7
|
+
- simplified CI to a single `main.yml` workflow with Ruby lint, Ruby tests, and JS tests
|
|
8
|
+
- aligned repository tooling with the release workflow used in `price_scanner`
|
|
9
|
+
|
|
10
|
+
## 0.1.1
|
|
11
|
+
|
|
12
|
+
- first public RubyGems release
|
|
13
|
+
- English README for public consumers
|
|
14
|
+
- agent-specific installation guidance moved to `AGENTS.md` and `CLAUDE.md`
|
|
15
|
+
- CI reduced to the minimum useful cross-platform matrix: Ruby 3.2 on Linux and macOS
|
|
16
|
+
- lockfile updated for Linux and generic Ruby platforms
|
|
17
|
+
|
|
18
|
+
## 0.1.0
|
|
19
|
+
|
|
20
|
+
- first public release candidate
|
|
21
|
+
- Rails 8 engine with helper, partial, installer and importmap support
|
|
22
|
+
- Stimulus password strength controller with show/hide toggle
|
|
23
|
+
- vendored `zxcvbn.js` for host apps without Node
|
|
24
|
+
- explicit `PasswordPolicy` pattern for syncing backend rules with UI requirements
|
|
25
|
+
- Ruby tests for helper rendering, generator behavior and dummy app smoke flow
|
|
26
|
+
- lightweight JS tests for controller behavior
|
|
27
|
+
- public README rewritten in English
|
|
28
|
+
- agent-specific integration instructions moved to `AGENTS.md` and `CLAUDE.md`
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
For integration guidance, treat [AGENTS.md](AGENTS.md) as the canonical source.
|
|
4
|
+
|
|
5
|
+
## Claude-Specific Notes
|
|
6
|
+
|
|
7
|
+
1. Start with a UI-only rollout unless the host app explicitly asks to simplify backend password policy.
|
|
8
|
+
2. Prefer using the generated `PasswordPolicy` as the source of truth for both model validation and gem requirements.
|
|
9
|
+
3. Update every user-facing password flow, not just signup.
|
|
10
|
+
4. If the host app still requires `password_confirmation`, keep it until validations and tests are migrated deliberately.
|
|
11
|
+
5. After integration, run a smoke test for signup, password reset, autofill, and password manager overlay behavior.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Justyna Wojtczak
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Publish Checklist
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Ship `stimulus-password-strength` as a reusable Rails 8+ gem with verified install path, stable UI API and enough automated coverage to publish safely.
|
|
6
|
+
|
|
7
|
+
## Tasks
|
|
8
|
+
|
|
9
|
+
### 1. Repo hygiene
|
|
10
|
+
|
|
11
|
+
- [x] remove built gem artifact from version control flow
|
|
12
|
+
- [x] ignore `node_modules` and built `.gem` files
|
|
13
|
+
- [x] ensure gemspec ships README and LICENSE
|
|
14
|
+
|
|
15
|
+
**DoD**
|
|
16
|
+
- `git status` shows no accidental build outputs tracked
|
|
17
|
+
- `.gitignore` blocks local artifacts
|
|
18
|
+
- built gem includes docs/license files
|
|
19
|
+
|
|
20
|
+
### 2. Public release metadata
|
|
21
|
+
|
|
22
|
+
- [x] add release notes file
|
|
23
|
+
- [x] enable RubyGems MFA metadata
|
|
24
|
+
- [x] make README default to published install flow
|
|
25
|
+
|
|
26
|
+
**DoD**
|
|
27
|
+
- `CHANGELOG.md` exists
|
|
28
|
+
- gemspec includes `rubygems_mfa_required`
|
|
29
|
+
- README installation example does not depend on local `path:`
|
|
30
|
+
|
|
31
|
+
### 3. Helper and rendering coverage
|
|
32
|
+
|
|
33
|
+
- [x] test rendered markup from `password_strength_field`
|
|
34
|
+
- [x] verify i18n labels and requirement serialization
|
|
35
|
+
- [x] verify invalid requirement rules fail fast
|
|
36
|
+
|
|
37
|
+
**DoD**
|
|
38
|
+
- helper test covers main HTML contract consumed by Stimulus
|
|
39
|
+
- tests assert both default and localized output
|
|
40
|
+
- unsupported requirements raise a clear error
|
|
41
|
+
|
|
42
|
+
### 4. Installer coverage
|
|
43
|
+
|
|
44
|
+
- [x] test importmap pin insertion
|
|
45
|
+
- [x] test Stimulus controller registration
|
|
46
|
+
- [x] test initializer creation
|
|
47
|
+
- [x] test `PasswordPolicy` template creation
|
|
48
|
+
- [x] test idempotency
|
|
49
|
+
|
|
50
|
+
**DoD**
|
|
51
|
+
- generator test proves a fresh host app gets all required files
|
|
52
|
+
- rerunning installer does not duplicate pins or controller registration
|
|
53
|
+
|
|
54
|
+
### 5. Dummy app smoke test
|
|
55
|
+
|
|
56
|
+
- [x] add minimal Rails dummy app
|
|
57
|
+
- [x] render a form using the gem helper
|
|
58
|
+
- [x] assert response contains the expected password field contract
|
|
59
|
+
|
|
60
|
+
**DoD**
|
|
61
|
+
- dummy app boots in test environment
|
|
62
|
+
- integration test hits a real route and renders the component successfully
|
|
63
|
+
|
|
64
|
+
### 6. JS behavior coverage
|
|
65
|
+
|
|
66
|
+
- [x] add lightweight Node-based tests for the Stimulus controller
|
|
67
|
+
- [x] test toggle behavior
|
|
68
|
+
- [x] test requirement label transitions
|
|
69
|
+
- [x] test hidden state for empty password
|
|
70
|
+
|
|
71
|
+
**DoD**
|
|
72
|
+
- JS tests run without bundling the host app
|
|
73
|
+
- core controller UX behavior is covered by automation
|
|
74
|
+
|
|
75
|
+
### 7. Verification
|
|
76
|
+
|
|
77
|
+
- [x] run Ruby test suite
|
|
78
|
+
- [x] run JS test suite
|
|
79
|
+
|
|
80
|
+
**DoD**
|
|
81
|
+
- both suites pass locally
|
|
82
|
+
- release candidate is blocked only by product decisions, not missing test scaffolding
|
data/README.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# stimulus-password-strength
|
|
2
|
+
|
|
3
|
+
Importmap-friendly password strength field for Rails 8+ with Stimulus, `zxcvbn`, and Tailwind-friendly markup.
|
|
4
|
+
|
|
5
|
+
## Product Goal
|
|
6
|
+
|
|
7
|
+
Reduce signup abandonment by improving password UX:
|
|
8
|
+
|
|
9
|
+
- one password field with `show/hide`
|
|
10
|
+
- real-time strength meter
|
|
11
|
+
- requirements placed above the input so they stay visible with 1Password/LastPass overlays
|
|
12
|
+
- no Node.js required in the host app
|
|
13
|
+
- simpler signup flow while keeping security standards and sound UX practices
|
|
14
|
+
|
|
15
|
+
## What the Gem Includes
|
|
16
|
+
|
|
17
|
+
- Rails engine: `StimulusPasswordStrength::Engine`
|
|
18
|
+
- Stimulus controller: `password-strength`
|
|
19
|
+
- vendored `zxcvbn.js`
|
|
20
|
+
- helper: `password_strength_field`
|
|
21
|
+
- partial: `_field.html.erb`
|
|
22
|
+
- installer: `rails g stimulus_password_strength:install`
|
|
23
|
+
- default i18n files: `en`, `pl`
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
Add the gem to your app:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
gem "stimulus-password-strength"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Then run:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
bundle install
|
|
37
|
+
bin/rails generate stimulus_password_strength:install
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The installer:
|
|
41
|
+
|
|
42
|
+
- adds importmap pins to `config/importmap.rb`
|
|
43
|
+
- registers the controller in `app/javascript/controllers/index.js`
|
|
44
|
+
- creates `config/initializers/stimulus_password_strength.rb`
|
|
45
|
+
- creates `app/lib/password_policy.rb` as the single source of truth for the rules shown in the UI
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
```erb
|
|
50
|
+
<%= form_with(model: @user) do |form| %>
|
|
51
|
+
<%= password_strength_field form, :password,
|
|
52
|
+
placeholder: "Minimum 12 characters",
|
|
53
|
+
requirements: PasswordPolicy.requirements %>
|
|
54
|
+
<% end %>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
With custom labels:
|
|
58
|
+
|
|
59
|
+
```erb
|
|
60
|
+
<%= password_strength_field form, :password,
|
|
61
|
+
strength_labels: { weak: "Weak", fair: "Fair", good: "Good", strong: "Strong" },
|
|
62
|
+
toggle_labels: { show: "Show", hide: "Hide" } %>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Password Policy
|
|
66
|
+
|
|
67
|
+
The installer creates a sample [password_policy.rb.tt](/Users/justi/projects_prod/stimulus-password-strength/lib/generators/stimulus_password_strength/install/templates/password_policy.rb.tt) file that should become the shared source of truth for:
|
|
68
|
+
|
|
69
|
+
- backend model validation
|
|
70
|
+
- requirements rendered by the gem
|
|
71
|
+
|
|
72
|
+
Example host app validation:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
# app/models/user.rb
|
|
76
|
+
validates :password, length: { minimum: PasswordPolicy::MIN_LENGTH }, allow_nil: true
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Example view usage:
|
|
80
|
+
|
|
81
|
+
```erb
|
|
82
|
+
<%= password_strength_field form, :password,
|
|
83
|
+
requirements: PasswordPolicy.requirements %>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`min_length` can use dynamic live copy from `PasswordPolicy`, for example:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
REQUIREMENTS = [
|
|
90
|
+
{
|
|
91
|
+
rule: :min_length,
|
|
92
|
+
value: MIN_LENGTH,
|
|
93
|
+
label: "At least #{MIN_LENGTH} characters",
|
|
94
|
+
remaining_singular: "Type 1 more character",
|
|
95
|
+
remaining_plural: "Type %{count} more characters",
|
|
96
|
+
met_label: "#{MIN_LENGTH}+ chars"
|
|
97
|
+
}
|
|
98
|
+
].freeze
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The gem does not try to infer rules from the model and does not add hidden fallbacks for `requirements`. The host app must pass policy explicitly from `PasswordPolicy`.
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
`config/initializers/stimulus_password_strength.rb`:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
StimulusPasswordStrength.configure do |config|
|
|
109
|
+
config.input_class = "w-full rounded-md border px-3 py-2 pr-16"
|
|
110
|
+
config.text_style = "min-width: 2.5rem; text-align: right; white-space: nowrap;"
|
|
111
|
+
config.status_row_class = "flex min-h-5 items-center gap-2"
|
|
112
|
+
config.requirements_style = "min-height: 1rem;"
|
|
113
|
+
config.requirement_pending_style = "color: #6b7280;"
|
|
114
|
+
config.requirement_met_style = "color: #047857;"
|
|
115
|
+
config.requirement_unmet_style = "color: #b91c1c;"
|
|
116
|
+
|
|
117
|
+
config.bar_colors = {
|
|
118
|
+
weak: "#f87171",
|
|
119
|
+
fair: "#fbbf24",
|
|
120
|
+
good: "#22c55e",
|
|
121
|
+
strong: "#059669"
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Adding more languages is standard Rails I18n: add another locale file in [config/locales](/Users/justi/projects_prod/stimulus-password-strength/config/locales).
|
|
127
|
+
|
|
128
|
+
## Post-Install Checklist
|
|
129
|
+
|
|
130
|
+
1. Signup: weak password -> backend validation still works.
|
|
131
|
+
2. Signup: `requirements` match `PasswordPolicy` and model validation.
|
|
132
|
+
3. Signup: `Show/Hide` toggle works on mobile and desktop.
|
|
133
|
+
4. Password reset: meter, requirements, and toggle behave the same way as signup.
|
|
134
|
+
5. Password autofill: the strength meter and requirements refresh correctly.
|
|
135
|
+
6. JS or `zxcvbn` failure: the form still allows submission.
|
|
136
|
+
7. i18n: `show/hide/weak/fair/good/strong` labels are correct for the current locale.
|
|
137
|
+
|
|
138
|
+
## Agent Guidance
|
|
139
|
+
|
|
140
|
+
If you are installing this gem through an AI coding agent, use:
|
|
141
|
+
|
|
142
|
+
- [AGENTS.md](AGENTS.md) for general agent instructions
|
|
143
|
+
- [CLAUDE.md](CLAUDE.md) for Claude-specific workflow notes
|
|
144
|
+
|
|
145
|
+
These files cover rollout order, host-app validation checks, smoke tests, and what should not be changed automatically.
|
|
146
|
+
|
|
147
|
+
## Example Adaptation: `linked_flow`
|
|
148
|
+
|
|
149
|
+
The gem was also used in `../linked_flow` as an example of a more opinionated UX simplification:
|
|
150
|
+
|
|
151
|
+
- signup and password reset work without `password_confirmation`
|
|
152
|
+
- the UI uses `password_strength_field`
|
|
153
|
+
- backend validation and UI both use a shared `PasswordPolicy` with minimum password length
|
|
154
|
+
|
|
155
|
+
Recommended rollout order:
|
|
156
|
+
|
|
157
|
+
1. Add the gem as UI-only.
|
|
158
|
+
2. Switch signup and reset views to `password_strength_field`.
|
|
159
|
+
3. Only then decide whether to simplify backend policy and business tests.
|
|
160
|
+
|
|
161
|
+
## Nice Follow-Ups After `0.1.0`
|
|
162
|
+
|
|
163
|
+
1. Generator for JS test scaffolding in the host app.
|
|
164
|
+
2. Wider CI coverage for Rails `8.0` and `8.1`.
|
|
165
|
+
3. `confirmation: true/false` helper option.
|
|
166
|
+
4. Public analytics event documentation for signup funnel instrumentation.
|
|
167
|
+
5. More examples for integrating with host app design systems.
|
|
168
|
+
|
|
169
|
+
## Development
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
cd /Users/justi/projects_prod/stimulus-password-strength
|
|
173
|
+
bundle install
|
|
174
|
+
npm install
|
|
175
|
+
bundle exec rake test
|
|
176
|
+
npm test
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Release Hygiene
|
|
180
|
+
|
|
181
|
+
- full publication checklist: [PUBLISH_CHECKLIST.md](PUBLISH_CHECKLIST.md)
|
|
182
|
+
- change history: [CHANGELOG.md](CHANGELOG.md)
|
data/Rakefile
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["input", "toggle", "statusRow", "strengthTrack", "strengthBar", "strengthText", "requirement", "showIcon", "hideIcon"]
|
|
5
|
+
|
|
6
|
+
connect() {
|
|
7
|
+
this.syncToggleState()
|
|
8
|
+
this.evaluate()
|
|
9
|
+
this.loadZxcvbn()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async loadZxcvbn() {
|
|
13
|
+
try {
|
|
14
|
+
const mod = await import("zxcvbn")
|
|
15
|
+
this.zxcvbn = mod.default || mod
|
|
16
|
+
this.evaluate()
|
|
17
|
+
} catch (error) {
|
|
18
|
+
this.zxcvbn = null
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
toggle() {
|
|
23
|
+
const isPassword = this.inputTarget.type === "password"
|
|
24
|
+
this.inputTarget.type = isPassword ? "text" : "password"
|
|
25
|
+
this.syncToggleState()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
evaluate() {
|
|
29
|
+
const password = this.inputTarget.value
|
|
30
|
+
|
|
31
|
+
if (password.length === 0) {
|
|
32
|
+
this.statusRowTarget.style.visibility = "hidden"
|
|
33
|
+
this.strengthTrackTarget.style.visibility = "hidden"
|
|
34
|
+
this.strengthBarTarget.style.width = "0%"
|
|
35
|
+
this.strengthBarTarget.style.backgroundColor = ""
|
|
36
|
+
this.strengthTextTarget.textContent = ""
|
|
37
|
+
this.strengthTextTarget.style.color = ""
|
|
38
|
+
this.updateRequirements(password)
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
this.statusRowTarget.style.visibility = "visible"
|
|
43
|
+
this.strengthTrackTarget.style.visibility = "visible"
|
|
44
|
+
const score = this.scoreFor(password)
|
|
45
|
+
const widths = [10, 25, 50, 75, 100]
|
|
46
|
+
const labels = ["weak", "weak", "fair", "good", "strong"]
|
|
47
|
+
|
|
48
|
+
this.strengthBarTarget.style.width = `${widths[score]}%`
|
|
49
|
+
this.strengthBarTarget.className = this.barClasses()
|
|
50
|
+
this.strengthBarTarget.style.backgroundColor = this.barColor(score)
|
|
51
|
+
this.strengthTextTarget.textContent = this.label(labels[score])
|
|
52
|
+
this.strengthTextTarget.className = this.textClasses()
|
|
53
|
+
this.strengthTextTarget.style.color = this.textColor(score)
|
|
54
|
+
this.updateRequirements(password)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
scoreFor(password) {
|
|
58
|
+
if (this.zxcvbn) {
|
|
59
|
+
return this.zxcvbn(password).score
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Fallback when zxcvbn cannot be loaded (offline/asset issue).
|
|
63
|
+
let score = 0
|
|
64
|
+
if (password.length >= 8) score += 1
|
|
65
|
+
if (password.length >= 12) score += 1
|
|
66
|
+
if (/[A-Z]/.test(password) && /[a-z]/.test(password)) score += 1
|
|
67
|
+
if (/\d/.test(password) || /[^A-Za-z0-9]/.test(password)) score += 1
|
|
68
|
+
return Math.min(score, 4)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
label(key) {
|
|
72
|
+
const map = {
|
|
73
|
+
weak: this.element.dataset.passwordStrengthWeakLabel,
|
|
74
|
+
fair: this.element.dataset.passwordStrengthFairLabel,
|
|
75
|
+
good: this.element.dataset.passwordStrengthGoodLabel,
|
|
76
|
+
strong: this.element.dataset.passwordStrengthStrongLabel
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return map[key] || key
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
barClasses() {
|
|
83
|
+
return this.element.dataset.passwordStrengthBarBaseClass || "h-full rounded-full transition-all duration-300"
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
textClasses() {
|
|
87
|
+
return this.element.dataset.passwordStrengthTextBaseClass || "text-xs"
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
barColor(score) {
|
|
91
|
+
const colors = [
|
|
92
|
+
this.element.dataset.passwordStrengthWeakBarColor || "#f87171",
|
|
93
|
+
this.element.dataset.passwordStrengthWeakBarColor || "#f87171",
|
|
94
|
+
this.element.dataset.passwordStrengthFairBarColor || "#fbbf24",
|
|
95
|
+
this.element.dataset.passwordStrengthGoodBarColor || "#22c55e",
|
|
96
|
+
this.element.dataset.passwordStrengthStrongBarColor || "#059669"
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
return colors[score]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
textColor(score) {
|
|
103
|
+
const colors = [
|
|
104
|
+
this.element.dataset.passwordStrengthWeakTextColor || "#ef4444",
|
|
105
|
+
this.element.dataset.passwordStrengthWeakTextColor || "#ef4444",
|
|
106
|
+
this.element.dataset.passwordStrengthFairTextColor || "#d97706",
|
|
107
|
+
this.element.dataset.passwordStrengthGoodTextColor || "#16a34a",
|
|
108
|
+
this.element.dataset.passwordStrengthStrongTextColor || "#047857"
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
return colors[score]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
syncToggleState() {
|
|
115
|
+
const isPassword = this.inputTarget.type === "password"
|
|
116
|
+
const label = isPassword
|
|
117
|
+
? this.toggleTarget.dataset.showLabel
|
|
118
|
+
: this.toggleTarget.dataset.hideLabel
|
|
119
|
+
|
|
120
|
+
this.toggleTarget.setAttribute("aria-label", label)
|
|
121
|
+
this.toggleTarget.setAttribute("title", label)
|
|
122
|
+
|
|
123
|
+
if (this.hasShowIconTarget) this.showIconTarget.hidden = !isPassword
|
|
124
|
+
if (this.hasHideIconTarget) this.hideIconTarget.hidden = isPassword
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
updateRequirements(password) {
|
|
128
|
+
this.requirementTargets.forEach((element) => {
|
|
129
|
+
const isMet = this.requirementMet(element, password)
|
|
130
|
+
|
|
131
|
+
if (password.length === 0) {
|
|
132
|
+
element.setAttribute("aria-hidden", "false")
|
|
133
|
+
element.innerHTML = this.escapeHtml(element.dataset.label)
|
|
134
|
+
element.style.cssText = `${element.dataset.pendingStyle}; visibility: visible;`
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
element.setAttribute("aria-hidden", "false")
|
|
139
|
+
element.innerHTML = isMet
|
|
140
|
+
? this.metRequirementMarkup(element)
|
|
141
|
+
: this.escapeHtml(this.requirementLabel(element, password))
|
|
142
|
+
element.style.cssText = isMet
|
|
143
|
+
? `${element.dataset.metStyle}; visibility: visible;`
|
|
144
|
+
: `${element.dataset.unmetStyle}; visibility: visible;`
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
requirementMet(element, password) {
|
|
149
|
+
switch (element.dataset.rule) {
|
|
150
|
+
case "min_length":
|
|
151
|
+
return password.length >= Number(element.dataset.value)
|
|
152
|
+
case "uppercase":
|
|
153
|
+
return /[A-Z]/.test(password)
|
|
154
|
+
default:
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
requirementLabel(element, password) {
|
|
160
|
+
switch (element.dataset.rule) {
|
|
161
|
+
case "min_length": {
|
|
162
|
+
const required = Number(element.dataset.value)
|
|
163
|
+
const remaining = Math.max(required - password.length, 0)
|
|
164
|
+
|
|
165
|
+
if (remaining === 1 && element.dataset.remainingSingular) {
|
|
166
|
+
return element.dataset.remainingSingular
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (remaining > 1 && element.dataset.remainingPlural) {
|
|
170
|
+
return element.dataset.remainingPlural.replace("%{count}", remaining)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return element.dataset.label
|
|
174
|
+
}
|
|
175
|
+
default:
|
|
176
|
+
return element.dataset.label
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
metRequirementMarkup(element) {
|
|
181
|
+
const label = this.escapeHtml(element.dataset.metLabel || element.dataset.label)
|
|
182
|
+
return `<span style="display: inline-flex; align-items: center; gap: 0.25rem;"><span aria-hidden="true">✓</span><span>${label}</span></span>`
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
escapeHtml(value) {
|
|
186
|
+
return String(value)
|
|
187
|
+
.replaceAll("&", "&")
|
|
188
|
+
.replaceAll("<", "<")
|
|
189
|
+
.replaceAll(">", ">")
|
|
190
|
+
.replaceAll('"', """)
|
|
191
|
+
.replaceAll("'", "'")
|
|
192
|
+
}
|
|
193
|
+
}
|