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.
- checksums.yaml +4 -4
- data/README.md +49 -5
- data/app/assets/javascripts/lato/controllers/lato_account_security_controller.js +51 -0
- data/app/assets/javascripts/lato/controllers/lato_account_webauthn_controller.js +8 -5
- data/app/assets/javascripts/lato/controllers/lato_action_controller.js +26 -15
- data/app/assets/javascripts/lato/controllers/lato_confirm_controller.js +25 -15
- data/app/assets/javascripts/lato/controllers/lato_copyclip_controller.js +28 -10
- data/app/assets/javascripts/lato/controllers/lato_form_controller.js +1 -0
- data/app/assets/javascripts/lato/controllers/lato_index_controller.js +4 -1
- data/app/controllers/concerns/lato/componentable.rb +20 -8
- data/app/controllers/concerns/lato/sessionable.rb +58 -16
- data/app/controllers/lato/account_controller.rb +98 -24
- data/app/controllers/lato/application_controller.rb +25 -1
- data/app/controllers/lato/authentication_controller.rb +74 -39
- data/app/helpers/lato/components_helper.rb +42 -16
- data/app/jobs/lato/active_storage_cleaner_job.rb +8 -7
- data/app/models/lato/invitation.rb +1 -1
- data/app/models/lato/operation.rb +6 -2
- data/app/models/lato/session.rb +73 -19
- data/app/models/lato/session_record.rb +5 -0
- data/app/models/lato/user.rb +66 -26
- data/app/views/lato/account/_alert-accepted-privacy-policy-version.html.erb +1 -1
- data/app/views/lato/account/_alert-accepted-terms-and-conditions-version.html.erb +1 -1
- data/app/views/lato/account/_form-authenticator.html.erb +19 -33
- data/app/views/lato/account/_form-destroy.html.erb +1 -0
- data/app/views/lato/account/_form-password.html.erb +4 -2
- data/app/views/lato/account/_form-user.html.erb +1 -0
- data/app/views/lato/account/_form-web3.html.erb +5 -3
- data/app/views/lato/account/_form-webauthn.html.erb +1 -0
- data/app/views/lato/account/_security-confirmation.html.erb +12 -0
- data/app/views/lato/authentication/_fields-registration.html.erb +8 -7
- data/app/views/lato/authentication/_form-authenticator.html.erb +2 -2
- data/app/views/lato/authentication/_form-signin.html.erb +2 -2
- data/app/views/lato/authentication/_form-verify-email.html.erb +3 -2
- data/app/views/lato/authentication/_form-web3-signin.html.erb +4 -3
- data/app/views/lato/components/_index.html.erb +25 -20
- data/app/views/lato/components/_navbar_nav_item.html.erb +1 -1
- data/app/views/lato/components/_operation.html.erb +4 -1
- data/app/views/lato/components/_page_head.html.erb +1 -1
- data/app/views/lato/components/_sidebar_nav_item.html.erb +2 -1
- data/app/views/lato/mailer/invitation/invite_mail.html.erb +1 -1
- data/app/views/lato/mailer/user/email_verification_mail.html.erb +1 -1
- data/app/views/lato/mailer/user/password_update_mail.html.erb +1 -1
- data/app/views/lato/mailer/user/signin_success_mail.html.erb +1 -1
- data/app/views/layouts/lato/_action.html.erb +2 -2
- data/app/views/layouts/lato/_aside-opener.html.erb +2 -2
- data/app/views/layouts/lato/_confirm.html.erb +6 -8
- data/app/views/layouts/lato/_feedbacks.html.erb +9 -8
- data/app/views/layouts/lato/_mailer-head_content.html.erb +1 -1
- data/app/views/layouts/lato/_network.html.erb +3 -3
- data/app/views/layouts/lato/application.html.erb +3 -2
- data/config/locales/en.yml +32 -0
- data/config/locales/fr.yml +29 -0
- data/config/locales/it.yml +29 -0
- data/config/locales/ro.yml +29 -0
- data/config/routes.rb +1 -0
- data/db/migrate/20261024000000_create_lato_session_records.rb +16 -0
- data/db/migrate/20261110000000_add_second_factor_counters_to_lato_users.rb +6 -0
- data/lib/lato/config.rb +4 -0
- data/lib/lato/engine.rb +9 -0
- data/lib/lato/version.rb +1 -1
- metadata +34 -17
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4fbacf743cd38a4c4b42cc018a23fa40107f40d08d076ca05a5cd2ac539f84f2
|
|
4
|
+
data.tar.gz: 54c1b642c8aa9b42136b773067207b36cb2760b4695e5b8ecb073e8f798e830d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 **
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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].
|
|
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.
|
|
66
|
+
modalTitleTarget.textContent = ''
|
|
60
67
|
})
|
|
61
68
|
|
|
62
69
|
// clean modalBody content
|
|
63
70
|
this.modalBodyTargets.forEach((modalBodyTarget) => {
|
|
64
|
-
modalBodyTarget.
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
28
|
-
this.
|
|
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.
|
|
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.
|
|
41
|
-
this.
|
|
48
|
+
this.resolveConfirm(true)
|
|
49
|
+
this.modal.hide()
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
confirmCancel() {
|
|
45
|
-
this.
|
|
46
|
-
this.
|
|
53
|
+
this.resolveConfirm(false)
|
|
54
|
+
this.modal.hide()
|
|
47
55
|
}
|
|
48
56
|
|
|
49
|
-
|
|
50
|
-
this.
|
|
51
|
-
|
|
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
|
-
|
|
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.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (this.
|
|
36
|
-
this.
|
|
37
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
attempts
|
|
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:
|
|
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
|
-
|
|
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:
|
|
53
|
-
expires:
|
|
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
|
-
|
|
103
|
+
@session&.destroy
|
|
104
|
+
cookies.delete(:lato_session)
|
|
105
|
+
reset_session
|
|
64
106
|
|
|
65
107
|
true
|
|
66
108
|
end
|