lato 3.24.0 → 3.26.0

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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +49 -5
  3. data/app/assets/javascripts/lato/controllers/lato_account_security_controller.js +51 -0
  4. data/app/assets/javascripts/lato/controllers/lato_account_webauthn_controller.js +8 -5
  5. data/app/assets/javascripts/lato/controllers/lato_action_controller.js +26 -15
  6. data/app/assets/javascripts/lato/controllers/lato_confirm_controller.js +25 -15
  7. data/app/assets/javascripts/lato/controllers/lato_copyclip_controller.js +28 -10
  8. data/app/assets/javascripts/lato/controllers/lato_form_controller.js +1 -0
  9. data/app/assets/javascripts/lato/controllers/lato_index_controller.js +4 -1
  10. data/app/controllers/concerns/lato/componentable.rb +20 -8
  11. data/app/controllers/concerns/lato/sessionable.rb +58 -16
  12. data/app/controllers/lato/account_controller.rb +98 -24
  13. data/app/controllers/lato/application_controller.rb +25 -1
  14. data/app/controllers/lato/authentication_controller.rb +74 -39
  15. data/app/helpers/lato/components_helper.rb +42 -16
  16. data/app/jobs/lato/active_storage_cleaner_job.rb +8 -7
  17. data/app/models/lato/invitation.rb +1 -1
  18. data/app/models/lato/operation.rb +6 -2
  19. data/app/models/lato/session.rb +73 -19
  20. data/app/models/lato/session_record.rb +5 -0
  21. data/app/models/lato/user.rb +66 -26
  22. data/app/views/lato/account/_alert-accepted-privacy-policy-version.html.erb +1 -1
  23. data/app/views/lato/account/_alert-accepted-terms-and-conditions-version.html.erb +1 -1
  24. data/app/views/lato/account/_form-authenticator.html.erb +19 -33
  25. data/app/views/lato/account/_form-destroy.html.erb +1 -0
  26. data/app/views/lato/account/_form-password.html.erb +4 -2
  27. data/app/views/lato/account/_form-user.html.erb +1 -0
  28. data/app/views/lato/account/_form-web3.html.erb +5 -3
  29. data/app/views/lato/account/_form-webauthn.html.erb +1 -0
  30. data/app/views/lato/account/_security-confirmation.html.erb +12 -0
  31. data/app/views/lato/authentication/_fields-registration.html.erb +8 -7
  32. data/app/views/lato/authentication/_form-authenticator.html.erb +2 -2
  33. data/app/views/lato/authentication/_form-signin.html.erb +2 -2
  34. data/app/views/lato/authentication/_form-verify-email.html.erb +3 -2
  35. data/app/views/lato/authentication/_form-web3-signin.html.erb +4 -3
  36. data/app/views/lato/components/_index.html.erb +25 -20
  37. data/app/views/lato/components/_navbar_nav_item.html.erb +1 -1
  38. data/app/views/lato/components/_operation.html.erb +4 -1
  39. data/app/views/lato/components/_page_head.html.erb +1 -1
  40. data/app/views/lato/components/_sidebar_nav_item.html.erb +2 -1
  41. data/app/views/lato/mailer/invitation/invite_mail.html.erb +1 -1
  42. data/app/views/lato/mailer/user/email_verification_mail.html.erb +1 -1
  43. data/app/views/lato/mailer/user/password_update_mail.html.erb +1 -1
  44. data/app/views/lato/mailer/user/signin_success_mail.html.erb +1 -1
  45. data/app/views/layouts/lato/_action.html.erb +2 -2
  46. data/app/views/layouts/lato/_aside-opener.html.erb +2 -2
  47. data/app/views/layouts/lato/_confirm.html.erb +6 -8
  48. data/app/views/layouts/lato/_feedbacks.html.erb +9 -8
  49. data/app/views/layouts/lato/_mailer-head_content.html.erb +1 -1
  50. data/app/views/layouts/lato/_network.html.erb +3 -3
  51. data/app/views/layouts/lato/application.html.erb +3 -2
  52. data/config/locales/en.yml +32 -0
  53. data/config/locales/fr.yml +29 -0
  54. data/config/locales/it.yml +29 -0
  55. data/config/locales/ro.yml +29 -0
  56. data/config/routes.rb +1 -0
  57. data/db/migrate/20261024000000_create_lato_session_records.rb +16 -0
  58. data/db/migrate/20261110000000_add_second_factor_counters_to_lato_users.rb +6 -0
  59. data/lib/lato/config.rb +4 -0
  60. data/lib/lato/engine.rb +9 -0
  61. data/lib/lato/version.rb +1 -1
  62. metadata +34 -17
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '09a445f7a4001cf512ef060934adeea3a0fd7620ebc7a8334bb95ea3e637939f'
4
- data.tar.gz: aac7020d5b8ae8698a46249870543cb25cb193b2e1d910f856381fd5d1945f72
3
+ metadata.gz: 4fbacf743cd38a4c4b42cc018a23fa40107f40d08d076ca05a5cd2ac539f84f2
4
+ data.tar.gz: 54c1b642c8aa9b42136b773067207b36cb2760b4695e5b8ecb073e8f798e830d
5
5
  SHA512:
6
- metadata.gz: eeaccec11e915e024139107529d49d3878b341de85ecebb9551ce7367cbacc591362d93da08f6760eb5372eb844277e0d31c835923a20b82d240d926b4e599ba
7
- data.tar.gz: a4864739c065fe216835e04b509620c7748fff88a51e8dc57849f9f7515518f0fdb3ab5e9b69f30adefb2680a48e9a149b630674f31dfeae442b919c13997a5f
6
+ metadata.gz: f1605ffef039889624f834a870709c910dbb614a78868cf4c0a84d0925dc7f7c72f8b1db8a6c0527ea422c327dec75c58b27a342a32e9ae49d5b4ceedcbf0210
7
+ data.tar.gz: 52fc4da5a54c9d35f2dd4fd324f20e750496d1bf7bf8b787129795b66d565c68742b1ec2803e87bf1c055a54760a216b72e60d78af61a256b6c18fc3c50042a7
data/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  Lato is a Rails Engine that offers a comprehensive web application starter kit with user management, authentication, and UI components for [Ruby on Rails](https://rubyonrails.org/).
6
6
 
7
7
  This gem includes:
8
- - User management with **full authentication system** (signin, signup, recover password, email validation, Google Authenticator multi-factor authentication, ETH wallet connection) and **account management** (user settings, password change, email change, etc.);
8
+ - User management with **full authentication system** (signin, signup, recover password, email validation, Google Authenticator and WebAuthn/passkey multi-factor authentication, ETH wallet connection) and **account management** (user settings, password change, email change, etc.);
9
9
  - **Web interface** built with [Bootstrap](https://getbootstrap.com/) and [Bootstrap Icons](https://icons.getbootstrap.com/);
10
10
  - **UI components integrated with Rails** (data tables, forms, modals, async job executions etc.);
11
11
 
@@ -13,7 +13,7 @@ The gem is **agnostic to the application stack**. You can use it with different
13
13
 
14
14
  The gem is designed to be easily **customizable and extendable**. You can fully customize the UI and the functionalities of the panel. You can also add new functionalities and components to the panel on the main application or develop new engines to extend the panel.
15
15
 
16
- The gem is ready to be used with the **latest Rails 7+** features like **[ESM import maps](https://github.com/rails/importmap-rails)**, **[Turbo](https://turbo.hotwired.dev/)** and **[Stimulus](https://stimulus.hotwired.dev)**. The front-end is designed to be responsive, mobile friendly and accessible.
16
+ The gem is ready to be used with the **Rails 8.1.3.1 or later within the 8.1 series** features like **[ESM import maps](https://github.com/rails/importmap-rails)**, **[Turbo](https://turbo.hotwired.dev/)** and **[Stimulus](https://stimulus.hotwired.dev)**. The front-end is designed to be responsive and mobile friendly, with accessibility improvements in progress (see the component notes in the documentation).
17
17
 
18
18
  <img src="./preview.gif" alt="Lato preview" width="100%">
19
19
 
@@ -41,13 +41,13 @@ Add required dependencies to your application's Gemfile:
41
41
 
42
42
  ```ruby
43
43
  # Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
44
- gem "importmap-rails" # NOTE: Probably already installed in default rails 7+ project
44
+ gem "importmap-rails" # NOTE: Probably already installed in default Rails 8 project
45
45
 
46
46
  # Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
47
- gem "turbo-rails" # NOTE: Probably already installed in default rails 7+ project
47
+ gem "turbo-rails" # NOTE: Probably already installed in default Rails 8 project
48
48
 
49
49
  # Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
50
- gem "stimulus-rails" # NOTE: Probably already installed in default rails 7+ project
50
+ gem "stimulus-rails" # NOTE: Probably already installed in default Rails 8 project
51
51
 
52
52
  # Use Sass to process CSS
53
53
  gem "sassc-rails"
@@ -75,6 +75,10 @@ rails lato:install:migrations
75
75
  rails db:migrate
76
76
  ```
77
77
 
78
+ Lato stores rate limits, password recovery codes, email verification codes and semaphores in `Rails.cache`. Use a persistent cache shared across processes (Redis, Solid Cache, Memcached): with `:null_store` password recovery never works and rate limits are disabled, with `:memory_store` and multiple processes limits are per process.
79
+
80
+ To use the Web3 (ETH wallet) authentication add `gem "eth"` to your Gemfile, it is not a dependency of Lato.
81
+
78
82
  Mount lato routes on the **config/routes.rb** file:
79
83
 
80
84
  ```ruby
@@ -113,6 +117,8 @@ end
113
117
 
114
118
  ```
115
119
 
120
+ Lato ships translations for `it`, `en`, `fr` and `ro`. Any key can be overridden in your application locale files. Emails exist only in Italian and English: users with locale `it` receive Italian emails, every other locale receives English.
121
+
116
122
  ### Extra tasks
117
123
  Lato integrates by default a basic PWAs manifest and service worker. To re-generate them with all required assets follow these steps:
118
124
 
@@ -136,6 +142,44 @@ rails lato:install:migrations
136
142
  rails db:migrate
137
143
  ```
138
144
 
145
+ ### Upgrading to the latest release
146
+
147
+ - Run `rails lato:install:migrations && rails db:migrate`: the release adds `authenticator_last_used_at` and `webauthn_sign_count` to `lato_users`.
148
+ - `lato_page_head(title)` now escapes the title. Pass an `html_safe` string to render HTML.
149
+ - `lato_form_item_input_check` escapes the label unless it is `html_safe`.
150
+ - `data-turbo-confirm` messages and `data-action-title` are rendered as plain text.
151
+ - New config `Lato.config.auth_password_min_length` (default 8), checked whenever a password is set.
152
+ - Rate limits are stricter: `limit_requests` allows exactly `limit` requests per IP and action, signin is also limited per email (10 failed attempts in 15 minutes) and second factor attempts are counted per user. The pending second factor step expires after 10 minutes or 5 wrong codes.
153
+ - TOTP codes can be used only once and the WebAuthn signature counter is enforced.
154
+ - Web3 signin uses a message bound to host, nonce and issue time (single use, 5 minutes) and requires `gem "eth"`.
155
+ - `switch_locale` redirects only to same-host referers. The GET route `switch_locale_get` is deprecated: use POST `lato.switch_locale_path(locale:)`.
156
+ - `Lato::Operation#start` only starts classes inheriting from `Lato::ApplicationJob`.
157
+ - Schedule the maintenance jobs `Lato::LatoOperationsCleanerJob` and `Lato::ActiveStorageCleanerJob` (see the operations documentation).
158
+
159
+ ### Upgrading to 3.25
160
+
161
+ Lato 3.25 requires Rails >= 8.1.3.1 and < 8.2. Install the new session migration before serving requests (`rails lato:install:migrations && rails db:migrate`).
162
+
163
+ Sessions are stored as revocable token hashes in the database. Existing cookies require a fresh sign-in after upgrading (global logout). Signing out revokes the current device; changing or recovering a password revokes all devices. Password recovery preserves existing second factors and does not disable them.
164
+
165
+ Account password, email, authentication factor, wallet, and account deletion changes require the current password plus an enrolled authenticator or security key. Authenticator enrollment requires confirmation with a valid code; an active authenticator's secret is never displayed again. API clients submit `user[current_password]` and, when enrolled, `user[current_authenticator_code]`. Security key clients first POST to `account/security_challenge`, then submit the assertion as `user[current_webauthn_credential]`. Enrollment continuations expire after five minutes and can only be used once.
166
+
167
+ Extensions updating session attributes must call `session_update(extra_params)` to preserve the revocable session and secure cookie attributes.
168
+
169
+ ## Production security checklist
170
+
171
+ - Set `config.force_ssl = true`: the session cookie is `secure` only when the request is SSL.
172
+ - Use a persistent `Rails.cache` shared across processes (Redis, Solid Cache, Memcached). Recovery codes, verification codes and all rate limits depend on it; Lato logs a warning at boot in production if it is a `NullStore`.
173
+ - Set `config.action_controller.raise_on_open_redirects = true`.
174
+ - Content Security Policy: the Web3 and verify-email views use inline scripts, so those pages need `unsafe-inline` or nonces. `ethers` 5.7.2 is loaded from cdnjs with a dynamic `import()`: pinned to an exact version but without SRI. Vendor it if you need strict supply-chain control.
175
+ - `session_lifetime` (default 30 days) is an absolute lifetime of cookie and database record, with no sliding renewal or idle timeout.
176
+ - WebAuthn: use HTTPS in `webauthn_origin` (a string or an array of origins). There is one credential per user, no backup codes, and user verification is not required.
177
+ - hCaptcha is optional (`hcaptcha_site_key`, `hcaptcha_secret`) and protects signup and password recovery.
178
+ - Schedule `Lato::LatoOperationsCleanerJob` and `Lato::ActiveStorageCleanerJob`, they are not scheduled automatically.
179
+ - Invitations do not expire: destroy pending ones that are no longer needed.
180
+ - `authenticator_secret` is stored in clear in the database: consider Active Record encryption.
181
+ - Signin per-email limiting can be used to temporarily lock a known email: monitor failed signins.
182
+
139
183
  ## Development
140
184
 
141
185
  Clone repository, install dependencies, run migrations and start:
@@ -0,0 +1,51 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ export default class extends Controller {
4
+ static targets = ['credential', 'error']
5
+ static values = { challengeUrl: String, webauthn: Boolean }
6
+
7
+ connect () {
8
+ this.form = this.element.closest('form')
9
+ this.onSubmit = this.confirm.bind(this)
10
+ this.form.addEventListener('submit', this.onSubmit)
11
+ }
12
+
13
+ disconnect () {
14
+ this.form.removeEventListener('submit', this.onSubmit)
15
+ }
16
+
17
+ async confirm (event) {
18
+ if (!this.webauthnValue || this.confirmed) return
19
+ event.preventDefault()
20
+ event.stopImmediatePropagation()
21
+ this.errorTarget.textContent = ''
22
+ try {
23
+ const response = await fetch(this.challengeUrlValue, {
24
+ method: 'POST',
25
+ headers: { Accept: 'application/json', 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content }
26
+ })
27
+ if (!response.ok) throw new Error('Unable to request security key confirmation.')
28
+ const publicKey = await response.json()
29
+ const decode = value => Uint8Array.from(atob(value.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
30
+ publicKey.challenge = decode(publicKey.challenge)
31
+ publicKey.allowCredentials = publicKey.allowCredentials.map(credential => ({ ...credential, id: decode(credential.id) }))
32
+ const credential = await navigator.credentials.get({ publicKey })
33
+ const encode = buffer => btoa(String.fromCharCode(...new Uint8Array(buffer)))
34
+ this.credentialTarget.value = JSON.stringify({
35
+ id: credential.id,
36
+ rawId: encode(credential.rawId),
37
+ type: credential.type,
38
+ response: {
39
+ clientDataJSON: encode(credential.response.clientDataJSON),
40
+ authenticatorData: encode(credential.response.authenticatorData),
41
+ signature: encode(credential.response.signature),
42
+ userHandle: credential.response.userHandle ? encode(credential.response.userHandle) : null
43
+ }
44
+ })
45
+ this.confirmed = true
46
+ this.form.requestSubmit(event.submitter)
47
+ } catch (error) {
48
+ this.errorTarget.textContent = 'Security key confirmation failed. Try again.'
49
+ }
50
+ }
51
+ }
@@ -83,11 +83,14 @@ export default class extends Controller {
83
83
 
84
84
  showError(message) {
85
85
  if (this.hasStatusTarget) {
86
- this.statusTarget.innerHTML = `
87
- <div class="alert alert-danger">
88
- <h4 class="alert-heading">${message}</h4>
89
- </div>
90
- `
86
+ const alert = document.createElement('div')
87
+ alert.className = 'alert alert-danger'
88
+ alert.setAttribute('role', 'alert')
89
+ const heading = document.createElement('h4')
90
+ heading.className = 'alert-heading'
91
+ heading.textContent = message
92
+ alert.appendChild(heading)
93
+ this.statusTarget.replaceChildren(alert)
91
94
  }
92
95
  }
93
96
  }
@@ -17,6 +17,7 @@ export default class extends Controller {
17
17
  connect() {
18
18
  this.bsModals = []
19
19
  this.usedModals = []
20
+ this.returnFocusElements = []
20
21
 
21
22
  this.modalTargets.forEach((modalTarget, index) => {
22
23
  this.bsModals.push(new bootstrap.Modal(modalTarget))
@@ -39,10 +40,16 @@ export default class extends Controller {
39
40
  }
40
41
  }, 1)
41
42
  })
43
+ // Programmatic show() does not restore focus like data-bs-toggle does: give it back to the trigger.
44
+ modalTarget.addEventListener('hidden.bs.modal', () => {
45
+ const element = this.returnFocusElements[index]
46
+ this.returnFocusElements[index] = null
47
+ if (element && element.isConnected) element.focus()
48
+ })
42
49
  modalTarget.addEventListener('hide.bs.modal', () => {
43
50
  setTimeout(() => {
44
51
  this.usedModals = this.usedModals.filter((i) => i != index)
45
- this.modalBodyTargets[index].innerHTML = ''
52
+ this.modalBodyTargets[index].replaceChildren()
46
53
  }, 500)
47
54
  })
48
55
  })
@@ -56,12 +63,12 @@ export default class extends Controller {
56
63
 
57
64
  // clean modalTitle content
58
65
  this.modalTitleTargets.forEach((modalTitleTarget) => {
59
- modalTitleTarget.innerHTML = ''
66
+ modalTitleTarget.textContent = ''
60
67
  })
61
68
 
62
69
  // clean modalBody content
63
70
  this.modalBodyTargets.forEach((modalBodyTarget) => {
64
- modalBodyTarget.innerHTML = ''
71
+ modalBodyTarget.replaceChildren()
65
72
  })
66
73
  }
67
74
 
@@ -108,19 +115,22 @@ export default class extends Controller {
108
115
  const index = this.getFreeModalIndex()
109
116
 
110
117
  if (options.turboFrame && options.turboFrame != '_top') {
111
- this.modalBodyTargets[index].innerHTML = `
112
- <turbo-frame id="${options.turboFrame}">
113
- <div class="placeholder-glow">
114
- ${Array(options.actionRows).fill().map(() => `<span class="placeholder placeholder-lg col-12"></span>`).join('')}
115
- </div>
116
- </turbo-frame>
117
- `
118
-
119
- if (options.actionTitle) {
120
- this.modalTitleTargets[index].innerHTML = options.actionTitle
121
- } else {
122
- this.modalTitleTargets[index].innerHTML = ''
118
+ // Build nodes instead of HTML strings: frame id and title come from element attributes.
119
+ const turboFrame = document.createElement('turbo-frame')
120
+ turboFrame.id = options.turboFrame
121
+ turboFrame.setAttribute('aria-busy', 'true')
122
+ turboFrame.addEventListener('turbo:frame-load', () => turboFrame.removeAttribute('aria-busy'), { once: true })
123
+ const placeholder = document.createElement('div')
124
+ placeholder.className = 'placeholder-glow'
125
+ for (let i = 0; i < options.actionRows; i++) {
126
+ const row = document.createElement('span')
127
+ row.className = 'placeholder placeholder-lg col-12'
128
+ placeholder.appendChild(row)
123
129
  }
130
+ turboFrame.appendChild(placeholder)
131
+ this.modalBodyTargets[index].replaceChildren(turboFrame)
132
+
133
+ this.modalTitleTargets[index].textContent = options.actionTitle || ''
124
134
 
125
135
  if (options.actionSize) {
126
136
  this.modalDialogTargets[index].classList.remove('modal-md')
@@ -130,6 +140,7 @@ export default class extends Controller {
130
140
  this.modalDialogTargets[index].classList.add(`modal-${options.actionSize}`)
131
141
  }
132
142
 
143
+ this.returnFocusElements[index] = document.activeElement
133
144
  this.bsModals[index].show()
134
145
  }
135
146
  }
@@ -18,36 +18,46 @@ export default class extends Controller {
18
18
  if (bsModalBackdropElement) bsModalBackdropElement.style.zIndex = 2000
19
19
  }, 1)
20
20
  })
21
+
22
+ // Any dismissal (backdrop, ESC, close button) must settle the pending confirm, otherwise the form hangs.
23
+ this.element.addEventListener('hidden.bs.modal', () => {
24
+ this.resolveConfirm(false)
25
+ this.modalBodyTarget.textContent = ''
26
+ // Programmatic show() does not restore focus like data-bs-toggle does: give it back to the trigger.
27
+ if (this._returnFocusElement && this._returnFocusElement.isConnected) this._returnFocusElement.focus()
28
+ this._returnFocusElement = null
29
+ })
21
30
  }
22
31
 
23
32
  disconnect() {
33
+ this.resolveConfirm(false)
24
34
  Turbo.config.forms.confirm = window.confirm
25
35
  }
26
36
 
27
- async customConfirm(message) {
28
- this.modalBodyTarget.innerHTML = message
37
+ customConfirm(message) {
38
+ this.resolveConfirm(false)
39
+ this._returnFocusElement = document.activeElement
40
+ // textContent: the message comes from data-turbo-confirm and may include user data.
41
+ this.modalBodyTarget.textContent = message
29
42
  this.modal.show()
30
43
 
31
- this._result = undefined
32
- while (this._result === undefined) {
33
- await new Promise(resolve => setTimeout(resolve, 100))
34
- }
35
-
36
- return this._result
44
+ return new Promise(resolve => { this._resolve = resolve })
37
45
  }
38
46
 
39
47
  confirmSuccess() {
40
- this.hideModal()
41
- this._result = true
48
+ this.resolveConfirm(true)
49
+ this.modal.hide()
42
50
  }
43
51
 
44
52
  confirmCancel() {
45
- this.hideModal()
46
- this._result = false
53
+ this.resolveConfirm(false)
54
+ this.modal.hide()
47
55
  }
48
56
 
49
- hideModal() {
50
- this.modal.hide()
51
- setTimeout(() => this.modalBodyTarget.innerHTML = '', 500)
57
+ resolveConfirm(result) {
58
+ if (!this._resolve) return
59
+
60
+ this._resolve(result)
61
+ this._resolve = null
52
62
  }
53
63
  }
@@ -10,6 +10,18 @@ export default class extends Controller {
10
10
  if (this.hasButtonTarget) {
11
11
  this.buttonTargetOriginalHTML = this.buttonTarget.innerHTML
12
12
  }
13
+
14
+ // Live region created up front: screen readers only announce changes to a region already in the DOM.
15
+ this.statusElement = document.createElement('span')
16
+ this.statusElement.className = 'visually-hidden'
17
+ this.statusElement.setAttribute('role', 'status')
18
+ this.statusElement.setAttribute('aria-live', 'polite')
19
+ this.element.appendChild(this.statusElement)
20
+ }
21
+
22
+ disconnect() {
23
+ if (this._copyTimeout) clearTimeout(this._copyTimeout)
24
+ this.statusElement?.remove()
13
25
  }
14
26
 
15
27
  onButtonClick(e) {
@@ -18,7 +30,7 @@ export default class extends Controller {
18
30
  this.copy()
19
31
  }
20
32
 
21
- copy() {
33
+ async copy() {
22
34
  if (!this.hasInputTarget) return
23
35
 
24
36
  // Copy the text inside the text field
@@ -26,16 +38,22 @@ export default class extends Controller {
26
38
  this.inputTarget.setSelectionRange(0, 99999) // For mobile devices
27
39
 
28
40
  // Copy the text inside the text field
29
- navigator.clipboard.writeText(this.inputTarget.value)
41
+ try {
42
+ await navigator.clipboard.writeText(this.inputTarget.value)
43
+ } catch (error) {
44
+ console.error(error)
45
+ return
46
+ }
47
+
48
+ const copiedText = (this.hasButtonTarget && this.buttonTarget.dataset.copyText) || 'Copied!'
49
+ this.statusElement.textContent = copiedText
30
50
 
31
51
  // Manage button state
32
- if (this.hasButtonTarget) {
33
- this.buttonTarget.innerHTML = this.buttonTarget.dataset.copyText || 'Copied!'
34
-
35
- if (this._copyTimeout) clearTimeout(this._copyTimeout)
36
- this._copyTimeout = setTimeout(() => {
37
- this.buttonTarget.innerHTML = this.buttonTargetOriginalHTML
38
- }, 2000)
39
- }
52
+ if (this._copyTimeout) clearTimeout(this._copyTimeout)
53
+ if (this.hasButtonTarget) this.buttonTarget.textContent = copiedText
54
+ this._copyTimeout = setTimeout(() => {
55
+ if (this.hasButtonTarget) this.buttonTarget.innerHTML = this.buttonTargetOriginalHTML
56
+ this.statusElement.textContent = ''
57
+ }, 2000)
40
58
  }
41
59
  }
@@ -30,6 +30,7 @@ export default class extends Controller {
30
30
  // This event is used to remove the Bootstrap invalid class from the input when the user starts typing.
31
31
  onInputChange(e) {
32
32
  e.target.classList.remove('is-invalid')
33
+ e.target.removeAttribute('aria-invalid')
33
34
  }
34
35
 
35
36
  // NOTE: You can use this method to submit the form from outside the form or from custom DOM actions.
@@ -1,6 +1,9 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  export default class extends Controller {
4
+ static values = {
5
+ key: String
6
+ }
4
7
 
5
8
  /**
6
9
  * Stimulus
@@ -15,7 +18,7 @@ export default class extends Controller {
15
18
 
16
19
  // This event is used to reset the pagination when the user types in the search input to start a new search.
17
20
  onSearchKeyUp(e) {
18
- const pageInput = this.element.querySelector('input[name="page"]')
21
+ const pageInput = this.element.querySelector(`input[name="${this.keyValue}_page"]`)
19
22
  if (pageInput) pageInput.value = 1
20
23
  }
21
24
  }
@@ -35,14 +35,7 @@ module Lato
35
35
  if collection.respond_to?(:lato_index_search)
36
36
  collection = collection.lato_index_search(search)
37
37
  else
38
- query = @_lato_index[key][:searchable_columns].map do |key|
39
- if collection.column_for_attribute(key).type == :string || collection.column_for_attribute(key).type == :text
40
- "LOWER(#{key}) LIKE :search"
41
- else
42
- "CAST(#{key} AS TEXT) LIKE :search"
43
- end
44
- end
45
- collection = collection.where(query.join(' OR '), search: "%#{search.downcase.strip}%")
38
+ collection = collection.where(lato_index_search_condition(collection, @_lato_index[key][:searchable_columns], search))
46
39
  end
47
40
  end
48
41
 
@@ -64,6 +57,25 @@ module Lato
64
57
 
65
58
  private
66
59
 
60
+ # Built with Arel so columns are quoted and the CAST type fits the adapter (MySQL has no CAST AS TEXT).
61
+ # LIKE wildcards typed by the user are escaped, so "%" and "_" match literally.
62
+ def lato_index_search_condition(collection, columns, search)
63
+ escape_character = '!'
64
+ pattern = "%#{ActiveRecord::Base.sanitize_sql_like(search.downcase.strip, escape_character)}%"
65
+ cast_type = collection.connection.adapter_name.match?(/mysql|trilogy/i) ? 'CHAR' : 'TEXT'
66
+ table = collection.arel_table
67
+
68
+ columns.map do |column|
69
+ attribute = table[column]
70
+ value = if %i[string text].include?(collection.column_for_attribute(column).type)
71
+ Arel::Nodes::NamedFunction.new('LOWER', [attribute])
72
+ else
73
+ Arel::Nodes::NamedFunction.new('CAST', [attribute.as(Arel.sql(cast_type))])
74
+ end
75
+ value.matches(pattern, escape_character)
76
+ end.reduce(:or)
77
+ end
78
+
67
79
  def parse_lato_index_sort_by(sort_by, sortable_columns)
68
80
  sort_by_column, sort_by_order = sort_by.to_s.split("|", 2)
69
81
  sort_by_column = sort_by_column.to_s
@@ -32,35 +32,77 @@ module Lato
32
32
 
33
33
  def limit_requests(limit = 10, time_window = 10.minutes)
34
34
  cache_key = "Lato::Sessionable/limit_requests/#{controller_name}/#{action_name}/#{request.remote_ip}"
35
- attempts = Rails.cache.read(cache_key) || 0
36
-
37
- attempts += 1
38
- Rails.cache.write(cache_key, attempts, expires_in: time_window)
39
- return true unless attempts >= limit
35
+ # increment is atomic on shared stores, so parallel requests can not slip past the limit.
36
+ attempts = Rails.cache.increment(cache_key, 1, expires_in: time_window).to_i
37
+ return true if attempts <= limit
40
38
 
39
+ respond_to_too_many_requests(time_window)
40
+ false
41
+ end
42
+
43
+ # Per-account throttle on failed attempts: complements the per-IP limit against distributed brute force.
44
+ ACCOUNT_LIMIT_ATTEMPTS = 10
45
+ ACCOUNT_LIMIT_TIME_WINDOW = 15.minutes
46
+
47
+ def account_limit_reached?(identifier)
48
+ return false if identifier.blank?
49
+
50
+ reached = Rails.cache.read(account_limit_cache_key(identifier), raw: true).to_i >= ACCOUNT_LIMIT_ATTEMPTS
51
+ respond_to_too_many_requests(ACCOUNT_LIMIT_TIME_WINDOW) if reached
52
+ reached
53
+ end
54
+
55
+ def account_limit_track_failure(identifier)
56
+ return if identifier.blank?
57
+
58
+ Rails.cache.increment(account_limit_cache_key(identifier), 1, expires_in: ACCOUNT_LIMIT_TIME_WINDOW)
59
+ end
60
+
61
+ def account_limit_reset(identifier)
62
+ Rails.cache.delete(account_limit_cache_key(identifier)) if identifier.present?
63
+ end
64
+
65
+ def account_limit_cache_key(identifier)
66
+ "Lato::Sessionable/account_limit/#{Digest::SHA256.hexdigest(identifier.to_s.downcase.strip)}"
67
+ end
68
+
69
+ def respond_to_too_many_requests(time_window)
70
+ message = I18n.t('lato.too_many_requests', minutes: (time_window.to_i / 60.0).ceil)
41
71
  respond_to do |format|
42
- format.html { render plain: "Too many requests, please wait #{time_window.to_i / 60} minutes to retry.", status: :too_many_requests }
43
- format.json { render json: {}, status: :too_many_requests }
72
+ format.html { render plain: message, status: :too_many_requests }
73
+ format.json { render json: { error: message }, status: :too_many_requests }
44
74
  end
45
-
46
- false
47
75
  end
48
76
 
49
77
  def session_create(user_id)
50
- # httponly keeps the cookie out of reach of injected scripts; secure avoids leaking it over plain HTTP.
78
+ @session&.destroy
79
+ reset_session
80
+ write_session_cookie(Lato::Session.generate_session_per_user(user_id))
81
+ end
82
+
83
+ def session_update(extra_params)
84
+ value = @session.update(extra_params)
85
+ return false unless value
86
+
87
+ write_session_cookie(value)
88
+ end
89
+
90
+ def write_session_cookie(value)
91
+ @session = Lato::Session.new(value)
51
92
  cookies.encrypted[:lato_session] = {
52
- value: Lato::Session.generate_session_per_user(user_id),
53
- expires: Lato.config.session_lifetime.from_now,
93
+ value: value,
94
+ expires: @session.expires_at,
54
95
  httponly: true,
55
- secure: request.ssl?
96
+ secure: request.ssl?,
97
+ same_site: :lax
56
98
  }
57
- @session = Lato::Session.new(cookies.encrypted[:lato_session])
58
-
59
99
  true
60
100
  end
61
101
 
62
102
  def session_destroy
63
- cookies.encrypted[:lato_session] = nil
103
+ @session&.destroy
104
+ cookies.delete(:lato_session)
105
+ reset_session
64
106
 
65
107
  true
66
108
  end