rodauth-rails 1.4.0 → 1.5.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/CHANGELOG.md +26 -0
- data/README.md +39 -134
- data/lib/generators/rodauth/templates/INSTRUCTIONS +11 -1
- data/lib/generators/rodauth/templates/app/mailers/rodauth_mailer.rb +38 -52
- data/lib/generators/rodauth/templates/app/misc/rodauth_main.rb +9 -6
- data/lib/generators/rodauth/templates/app/models/account.rb +1 -0
- data/lib/generators/rodauth/templates/app/views/rodauth/add_recovery_codes.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/change_login.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/change_password.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/close_account.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/confirm_password.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/create_account.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/email_auth.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/login.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/logout.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/multi_phase_login.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/otp_auth.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/otp_disable.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/otp_setup.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/recovery_auth.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/recovery_codes.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/remember.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/reset_password.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/reset_password_request.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/sms_auth.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/sms_confirm.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/sms_disable.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/sms_request.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/sms_setup.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/two_factor_auth.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/two_factor_disable.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/two_factor_manage.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/unlock_account.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/unlock_account_request.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/verify_account.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/verify_account_resend.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/verify_login_change.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/webauthn_auth.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/webauthn_remove.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth/webauthn_setup.html.erb +0 -2
- data/lib/generators/rodauth/templates/app/views/rodauth_mailer/verify_login_change.text.erb +2 -2
- data/lib/rodauth/rails/feature/render.rb +8 -1
- data/lib/rodauth/rails/feature.rb +0 -2
- data/lib/rodauth/rails/model.rb +2 -97
- data/lib/rodauth/rails/version.rb +1 -1
- data/lib/rodauth/rails.rb +3 -2
- data/rodauth-rails.gemspec +1 -0
- metadata +16 -3
- data/lib/rodauth/rails/feature/associations.rb +0 -54
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 43e0f2c048024645cb8af7e744042187ded20eeca3f685bdf28a959aebf296e0
|
|
4
|
+
data.tar.gz: 96d02ad057f315a339bc1cda804f71e19cd629a9cb2ca5c574c515d954692673
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c943289cb0c628b37d89fcc6e2a0b22b190ba0e0c0351ffc53c726ac81b0c9f87b0b6e1f929a823724fa1aed70c5b5601279b24df5b54026cb06bb073c3b284c
|
|
7
|
+
data.tar.gz: afb5b6523b6c440c9b02255b047cf562dd0122a6abb8d49f7c93671981c75935d6a1a9ab1c27a9e8158578a928b70b3705db24760db803acd79abafa58cdc4ce
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
## 1.5.0 (2022-06-11)
|
|
2
|
+
|
|
3
|
+
* Remove `content_for` calls from generated view templates (@janko)
|
|
4
|
+
|
|
5
|
+
* Set title instance variable to `@page_title` in generated configuration (@janko)
|
|
6
|
+
|
|
7
|
+
* Set title instance variable on the controller when `title_instance_variable` is set (@HoneyryderChuck)
|
|
8
|
+
|
|
9
|
+
## 1.4.2 (2022-05-15)
|
|
10
|
+
|
|
11
|
+
* Stop passing email addresses in mailer arguments on verifying login change (@janko)
|
|
12
|
+
|
|
13
|
+
* Extract finding account into a method in the generated mailer (@janko)
|
|
14
|
+
|
|
15
|
+
* Make generated Action Mailer integration work with secondary Rodauth configurations (@janko)
|
|
16
|
+
|
|
17
|
+
* Include `Rodauth::Rails.model` in generated Sequel account model as well (@janko)
|
|
18
|
+
|
|
19
|
+
## 1.4.1 (2022-05-08)
|
|
20
|
+
|
|
21
|
+
* Deprecate `Rodauth::Rails::Model` constant (@janko)
|
|
22
|
+
|
|
23
|
+
* Remove `Rodauth::Rails::Auth#associations` in favour of new association registration API (@janko)
|
|
24
|
+
|
|
25
|
+
* Extract model mixin into the rodauth-model gem (@janko)
|
|
26
|
+
|
|
1
27
|
## 1.4.0 (2022-05-04)
|
|
2
28
|
|
|
3
29
|
* Move association definitions to `#associations` Rodauth method, allowing external features to extend them (@janko)
|
data/README.md
CHANGED
|
@@ -321,16 +321,26 @@ $ rails generate rodauth:views webauthn --name admin
|
|
|
321
321
|
|
|
322
322
|
#### Page titles
|
|
323
323
|
|
|
324
|
-
The generated
|
|
325
|
-
|
|
326
|
-
|
|
324
|
+
The generated configuration sets `title_instance_variable` to make page titles
|
|
325
|
+
available in your views via `@page_title` instance variable, which you can then
|
|
326
|
+
use in your layout:
|
|
327
327
|
|
|
328
|
+
```rb
|
|
329
|
+
# app/misc/rodauth_main.rb
|
|
330
|
+
class RodauthMain < Rodauth::Rails::Auth
|
|
331
|
+
configure do
|
|
332
|
+
# ...
|
|
333
|
+
title_instance_variable :@page_title
|
|
334
|
+
# ...
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
```
|
|
328
338
|
```erb
|
|
329
339
|
<!-- app/views/layouts/application.html.erb -->
|
|
330
340
|
<!DOCTYPE html>
|
|
331
341
|
<html>
|
|
332
342
|
<head>
|
|
333
|
-
<title><%=
|
|
343
|
+
<title><%= @page_title || "Default title" %></title>
|
|
334
344
|
<!-- ... -->
|
|
335
345
|
</head>
|
|
336
346
|
<body>
|
|
@@ -339,6 +349,21 @@ title:
|
|
|
339
349
|
</html>
|
|
340
350
|
```
|
|
341
351
|
|
|
352
|
+
If you're already setting page titles via `content_for`, you can use it in
|
|
353
|
+
generated Rodauth views, giving it the result of the corresponding
|
|
354
|
+
`*_page_title` method:
|
|
355
|
+
|
|
356
|
+
```erb
|
|
357
|
+
<!-- app/views/rodauth/login.html.erb -->
|
|
358
|
+
<%= content_for :page_title, rodauth.login_page_title %>
|
|
359
|
+
<!-- ... -->
|
|
360
|
+
```
|
|
361
|
+
```erb
|
|
362
|
+
<!-- app/views/rodauth/change_password.html.erb -->
|
|
363
|
+
<%= content_for :page_title, rodauth.change_password_page_title %>
|
|
364
|
+
<!-- ... -->
|
|
365
|
+
```
|
|
366
|
+
|
|
342
367
|
#### Layout
|
|
343
368
|
|
|
344
369
|
To use different layouts for different Rodauth views, you can compare the
|
|
@@ -484,21 +509,19 @@ end
|
|
|
484
509
|
|
|
485
510
|
## Model
|
|
486
511
|
|
|
487
|
-
The `Rodauth::
|
|
488
|
-
defines a password attribute and associations for
|
|
489
|
-
authentication features.
|
|
512
|
+
The [rodauth-model] gem provides a `Rodauth::Model` mixin that can be included
|
|
513
|
+
into the account model, which defines a password attribute and associations for
|
|
514
|
+
tables used by enabled authentication features.
|
|
490
515
|
|
|
491
516
|
```rb
|
|
492
|
-
class Account <
|
|
517
|
+
class Account < ActiveRecord::Base # Sequel::Model
|
|
493
518
|
include Rodauth::Rails.model # or `Rodauth::Rails.model(:admin)`
|
|
494
519
|
end
|
|
495
520
|
```
|
|
496
521
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
accounts table, or in a separate table, the `#password` attribute can be used
|
|
501
|
-
to set or clear the password hash.
|
|
522
|
+
The password attribute can be used to set or clear the password hash. It
|
|
523
|
+
handles both storing the password hash in a column on the accounts table, or in
|
|
524
|
+
a separate table.
|
|
502
525
|
|
|
503
526
|
```rb
|
|
504
527
|
account = Account.create!(email: "user@example.com", password: "secret")
|
|
@@ -514,132 +537,14 @@ account.password = nil # clears password hash
|
|
|
514
537
|
account.password_hash #=> nil
|
|
515
538
|
```
|
|
516
539
|
|
|
517
|
-
|
|
518
|
-
unsuitable for forms. It was primarily intended to allow easily creating
|
|
519
|
-
accounts in development console and in tests.
|
|
520
|
-
|
|
521
|
-
### Associations
|
|
522
|
-
|
|
523
|
-
The `Rodauth::Rails::Model` mixin defines associations for Rodauth tables
|
|
524
|
-
associated to the accounts table:
|
|
540
|
+
The associations are defined for tables used by enabled authentication features:
|
|
525
541
|
|
|
526
542
|
```rb
|
|
527
543
|
account.remember_key #=> #<Account::RememberKey> (record from `account_remember_keys` table)
|
|
528
544
|
account.active_session_keys #=> [#<Account::ActiveSessionKey>,...] (records from `account_active_session_keys` table)
|
|
529
545
|
```
|
|
530
546
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
```rb
|
|
534
|
-
# model referencing the `account_authentication_audit_logs` table
|
|
535
|
-
Account::AuthenticationAuditLog.where(message: "login").group(:account_id)
|
|
536
|
-
```
|
|
537
|
-
|
|
538
|
-
The associated models define the inverse `belongs_to :account` association:
|
|
539
|
-
|
|
540
|
-
```rb
|
|
541
|
-
Account::ActiveSessionKey.includes(:account).map(&:account)
|
|
542
|
-
```
|
|
543
|
-
|
|
544
|
-
Here is an example of using associations to create a method that returns
|
|
545
|
-
whether the account has multifactor authentication enabled:
|
|
546
|
-
|
|
547
|
-
```rb
|
|
548
|
-
class Account < ApplicationRecord
|
|
549
|
-
include Rodauth::Rails.model
|
|
550
|
-
|
|
551
|
-
def mfa_enabled?
|
|
552
|
-
otp_key || (sms_code && sms_code.num_failures.nil?) || recovery_codes.any?
|
|
553
|
-
end
|
|
554
|
-
end
|
|
555
|
-
```
|
|
556
|
-
|
|
557
|
-
Here is another example of creating a query scope that selects accounts with
|
|
558
|
-
multifactor authentication enabled:
|
|
559
|
-
|
|
560
|
-
```rb
|
|
561
|
-
class Account < ApplicationRecord
|
|
562
|
-
include Rodauth::Rails.model
|
|
563
|
-
|
|
564
|
-
scope :otp_setup, -> { where(otp_key: OtpKey.all) }
|
|
565
|
-
scope :sms_codes_setup, -> { where(sms_code: SmsCode.where(num_failures: nil)) }
|
|
566
|
-
scope :recovery_codes_setup, -> { where(recovery_codes: RecoveryCode.all) }
|
|
567
|
-
scope :mfa_enabled, -> { merge(otp_setup.or(sms_codes_setup).or(recovery_codes_setup)) }
|
|
568
|
-
end
|
|
569
|
-
```
|
|
570
|
-
|
|
571
|
-
#### Association reference
|
|
572
|
-
|
|
573
|
-
Below is a list of all associations defined depending on the features loaded:
|
|
574
|
-
|
|
575
|
-
| Feature | Association | Type | Model | Table (default) |
|
|
576
|
-
| :------ | :---------- | :--- | :---- | :---- |
|
|
577
|
-
| account_expiration | `:activity_time` | `has_one` | `ActivityTime` | `account_activity_times` |
|
|
578
|
-
| active_sessions | `:active_session_keys` | `has_many` | `ActiveSessionKey` | `account_active_session_keys` |
|
|
579
|
-
| audit_logging | `:authentication_audit_logs` | `has_many` | `AuthenticationAuditLog` | `account_authentication_audit_logs` |
|
|
580
|
-
| disallow_password_reuse | `:previous_password_hashes` | `has_many` | `PreviousPasswordHash` | `account_previous_password_hashes` |
|
|
581
|
-
| email_auth | `:email_auth_key` | `has_one` | `EmailAuthKey` | `account_email_auth_keys` |
|
|
582
|
-
| jwt_refresh | `:jwt_refresh_keys` | `has_many` | `JwtRefreshKey` | `account_jwt_refresh_keys` |
|
|
583
|
-
| lockout | `:lockout` | `has_one` | `Lockout` | `account_lockouts` |
|
|
584
|
-
| lockout | `:login_failure` | `has_one` | `LoginFailure` | `account_login_failures` |
|
|
585
|
-
| otp | `:otp_key` | `has_one` | `OtpKey` | `account_otp_keys` |
|
|
586
|
-
| password_expiration | `:password_change_time` | `has_one` | `PasswordChangeTime` | `account_password_change_times` |
|
|
587
|
-
| recovery_codes | `:recovery_codes` | `has_many` | `RecoveryCode` | `account_recovery_codes` |
|
|
588
|
-
| remember | `:remember_key` | `has_one` | `RememberKey` | `account_remember_keys` |
|
|
589
|
-
| reset_password | `:password_reset_key` | `has_one` | `PasswordResetKey` | `account_password_reset_keys` |
|
|
590
|
-
| single_session | `:session_key` | `has_one` | `SessionKey` | `account_session_keys` |
|
|
591
|
-
| sms_codes | `:sms_code` | `has_one` | `SmsCode` | `account_sms_codes` |
|
|
592
|
-
| verify_account | `:verification_key` | `has_one` | `VerificationKey` | `account_verification_keys` |
|
|
593
|
-
| verify_login_change | `:login_change_key` | `has_one` | `LoginChangeKey` | `account_login_change_keys` |
|
|
594
|
-
| webauthn | `:webauthn_keys` | `has_many` | `WebauthnKey` | `account_webauthn_keys` |
|
|
595
|
-
| webauthn | `:webauthn_user_id` | `has_one` | `WebauthnUserId` | `account_webauthn_user_ids` |
|
|
596
|
-
|
|
597
|
-
Note that some Rodauth tables use composite primary keys, which Active Record
|
|
598
|
-
doesn't support out of the box. For associations to work properly, you might
|
|
599
|
-
need to add the [composite_primary_keys] gem to your Gemfile.
|
|
600
|
-
|
|
601
|
-
#### Association options
|
|
602
|
-
|
|
603
|
-
By default, all associations except for audit logs have `dependent: :destroy`
|
|
604
|
-
set, to allow for easy deletion of account records in the console. You can use
|
|
605
|
-
`:association_options` to modify global or per-association options:
|
|
606
|
-
|
|
607
|
-
```rb
|
|
608
|
-
# don't auto-delete associations when account model is deleted
|
|
609
|
-
Rodauth::Rails.model(association_options: { dependent: nil })
|
|
610
|
-
|
|
611
|
-
# require authentication audit logs to be eager loaded before retrieval
|
|
612
|
-
Rodauth::Rails.model(association_options: -> (name) {
|
|
613
|
-
{ strict_loading: true } if name == :authentication_audit_logs
|
|
614
|
-
})
|
|
615
|
-
```
|
|
616
|
-
|
|
617
|
-
#### Extending Associations
|
|
618
|
-
|
|
619
|
-
External features can extend the list of associations with their own
|
|
620
|
-
definitions, which the model mixin will pick up and declare the new associations
|
|
621
|
-
on the model.
|
|
622
|
-
|
|
623
|
-
```rb
|
|
624
|
-
# lib/rodauth/features/foo.rb
|
|
625
|
-
module Rodauth
|
|
626
|
-
Feature.define(:foo, :Foo) do
|
|
627
|
-
auth_value_method :foo_table, :account_foos
|
|
628
|
-
auth_value_method :foo_id_column, :id
|
|
629
|
-
|
|
630
|
-
def associations
|
|
631
|
-
list = super
|
|
632
|
-
list << {
|
|
633
|
-
name: :foo, # will define `Account::Foo` model
|
|
634
|
-
type: :one, # or :many
|
|
635
|
-
table: foo_table,
|
|
636
|
-
foreign_key: foo_id_column
|
|
637
|
-
}
|
|
638
|
-
list
|
|
639
|
-
end
|
|
640
|
-
end
|
|
641
|
-
end
|
|
642
|
-
```
|
|
547
|
+
See the [rodauth-model] documentation for more details.
|
|
643
548
|
|
|
644
549
|
## Multiple configurations
|
|
645
550
|
|
|
@@ -1304,8 +1209,8 @@ conduct](https://github.com/janko/rodauth-rails/blob/master/CODE_OF_CONDUCT.md).
|
|
|
1304
1209
|
[account_expiration]: http://rodauth.jeremyevans.net/rdoc/files/doc/account_expiration_rdoc.html
|
|
1305
1210
|
[simple_ldap_authenticator]: https://github.com/jeremyevans/simple_ldap_authenticator
|
|
1306
1211
|
[internal_request]: http://rodauth.jeremyevans.net/rdoc/files/doc/internal_request_rdoc.html
|
|
1307
|
-
[composite_primary_keys]: https://github.com/composite-primary-keys/composite_primary_keys
|
|
1308
1212
|
[path_class_methods]: https://rodauth.jeremyevans.net/rdoc/files/doc/path_class_methods_rdoc.html
|
|
1309
1213
|
[account types]: https://github.com/janko/rodauth-rails/wiki/Account-Types
|
|
1310
1214
|
[custom mailer worker]: https://github.com/janko/rodauth-rails/wiki/Custom-Mailer-Worker
|
|
1311
1215
|
[Turbo]: https://turbo.hotwired.dev/
|
|
1216
|
+
[rodauth-model]: https://github.com/janko/rodauth-model
|
|
@@ -31,7 +31,17 @@ Depending on your application's configuration some manual setup may be required:
|
|
|
31
31
|
|
|
32
32
|
* Not required for API-only Applications *
|
|
33
33
|
|
|
34
|
-
4.
|
|
34
|
+
4. Titles for Rodauth pages are available via @page_title instance variable
|
|
35
|
+
by default, you can use it in your layout file:
|
|
36
|
+
|
|
37
|
+
<head>
|
|
38
|
+
<title><%= @page_title || "Default title" %></title>
|
|
39
|
+
...
|
|
40
|
+
</head>
|
|
41
|
+
|
|
42
|
+
* Not required *
|
|
43
|
+
|
|
44
|
+
5. You can copy Rodauth views (for customization) to your app by running:
|
|
35
45
|
|
|
36
46
|
rails g rodauth:views
|
|
37
47
|
|
|
@@ -1,78 +1,64 @@
|
|
|
1
1
|
class RodauthMailer < ApplicationMailer
|
|
2
|
-
def verify_account(account_id, key)
|
|
3
|
-
@email_link =
|
|
4
|
-
|
|
5
|
-
@account = Account.find(account_id)
|
|
6
|
-
<% else -%>
|
|
7
|
-
@account = Account.with_pk!(account_id)
|
|
8
|
-
<% end -%>
|
|
2
|
+
def verify_account(name = nil, account_id, key)
|
|
3
|
+
@email_link = email_link(name, :verify_account, account_id, key)
|
|
4
|
+
@account = find_account(name, account_id)
|
|
9
5
|
|
|
10
|
-
mail to: @account.email, subject: rodauth.verify_account_email_subject
|
|
6
|
+
mail to: @account.email, subject: rodauth(name).verify_account_email_subject
|
|
11
7
|
end
|
|
12
8
|
|
|
13
|
-
def reset_password(account_id, key)
|
|
14
|
-
@email_link =
|
|
15
|
-
|
|
16
|
-
@account = Account.find(account_id)
|
|
17
|
-
<% else -%>
|
|
18
|
-
@account = Account.with_pk!(account_id)
|
|
19
|
-
<% end -%>
|
|
9
|
+
def reset_password(name = nil, account_id, key)
|
|
10
|
+
@email_link = email_link(name, :reset_password, account_id, key)
|
|
11
|
+
@account = find_account(name, account_id)
|
|
20
12
|
|
|
21
|
-
mail to: @account.email, subject: rodauth.reset_password_email_subject
|
|
13
|
+
mail to: @account.email, subject: rodauth(name).reset_password_email_subject
|
|
22
14
|
end
|
|
23
15
|
|
|
24
|
-
def verify_login_change(
|
|
25
|
-
@
|
|
26
|
-
@
|
|
27
|
-
@
|
|
28
|
-
<% if defined?(ActiveRecord::Railtie) -%>
|
|
29
|
-
@account = Account.find(account_id)
|
|
30
|
-
<% else -%>
|
|
31
|
-
@account = Account.with_pk!(account_id)
|
|
32
|
-
<% end -%>
|
|
16
|
+
def verify_login_change(name = nil, account_id, key)
|
|
17
|
+
@email_link = email_link(name, :verify_login_change, account_id, key)
|
|
18
|
+
@account = find_account(name, account_id)
|
|
19
|
+
@new_email = @account.login_change_key.login
|
|
33
20
|
|
|
34
|
-
mail to:
|
|
21
|
+
mail to: @new_email, subject: rodauth(name).verify_login_change_email_subject
|
|
35
22
|
end
|
|
36
23
|
|
|
37
|
-
def password_changed(account_id)
|
|
38
|
-
|
|
39
|
-
@account = Account.find(account_id)
|
|
40
|
-
<% else -%>
|
|
41
|
-
@account = Account.with_pk!(account_id)
|
|
42
|
-
<% end -%>
|
|
24
|
+
def password_changed(name = nil, account_id)
|
|
25
|
+
@account = find_account(name, account_id)
|
|
43
26
|
|
|
44
|
-
mail to: @account.email, subject: rodauth.password_changed_email_subject
|
|
27
|
+
mail to: @account.email, subject: rodauth(name).password_changed_email_subject
|
|
45
28
|
end
|
|
46
29
|
|
|
47
|
-
# def email_auth(account_id, key)
|
|
48
|
-
# @email_link =
|
|
49
|
-
|
|
50
|
-
# @account = Account.find(account_id)
|
|
51
|
-
<% else -%>
|
|
52
|
-
# @account = Account.with_pk!(account_id)
|
|
53
|
-
<% end -%>
|
|
30
|
+
# def email_auth(name = nil, account_id, key)
|
|
31
|
+
# @email_link = email_link(name, :email_auth, account_id, key)
|
|
32
|
+
# @account = find_account(name, account_id)
|
|
54
33
|
|
|
55
|
-
# mail to: @account.email, subject: rodauth.email_auth_email_subject
|
|
34
|
+
# mail to: @account.email, subject: rodauth(name).email_auth_email_subject
|
|
56
35
|
# end
|
|
57
36
|
|
|
58
|
-
# def unlock_account(account_id, key)
|
|
59
|
-
# @email_link =
|
|
60
|
-
|
|
61
|
-
# @account = Account.find(account_id)
|
|
62
|
-
<% else -%>
|
|
63
|
-
# @account = Account.with_pk!(account_id)
|
|
64
|
-
<% end -%>
|
|
37
|
+
# def unlock_account(name = nil, account_id, key)
|
|
38
|
+
# @email_link = email_link(name, :unlock_account, account_id, key)
|
|
39
|
+
# @account = find_account(name, account_id)
|
|
65
40
|
|
|
66
|
-
# mail to: @account.email, subject: rodauth.unlock_account_email_subject
|
|
41
|
+
# mail to: @account.email, subject: rodauth(name).unlock_account_email_subject
|
|
67
42
|
# end
|
|
68
43
|
|
|
69
44
|
private
|
|
70
45
|
|
|
71
|
-
def
|
|
72
|
-
|
|
46
|
+
def find_account(_name, account_id)
|
|
47
|
+
<% if defined?(ActiveRecord::Railtie) -%>
|
|
48
|
+
Account.find(account_id)
|
|
49
|
+
<% else -%>
|
|
50
|
+
Account.with_pk!(account_id)
|
|
51
|
+
<% end -%>
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def email_link(name, action, account_id, key)
|
|
55
|
+
instance = rodauth(name)
|
|
56
|
+
instance.instance_variable_set(:@account, { id: account_id })
|
|
57
|
+
instance.instance_variable_set(:"@#{action}_key_value", key)
|
|
58
|
+
instance.public_send(:"#{action}_email_link")
|
|
73
59
|
end
|
|
74
60
|
|
|
75
|
-
def rodauth(name
|
|
61
|
+
def rodauth(name)
|
|
76
62
|
RodauthApp.rodauth(name).allocate
|
|
77
63
|
end
|
|
78
64
|
end
|
|
@@ -31,6 +31,9 @@ class RodauthMain < Rodauth::Rails::Auth
|
|
|
31
31
|
# Specify the controller used for view rendering and CSRF verification.
|
|
32
32
|
rails_controller { RodauthController }
|
|
33
33
|
|
|
34
|
+
# Set on Rodauth controller with the title of the current page.
|
|
35
|
+
title_instance_variable :@page_title
|
|
36
|
+
|
|
34
37
|
# Store account status in an integer column without foreign key constraint.
|
|
35
38
|
account_status_column :status
|
|
36
39
|
|
|
@@ -56,22 +59,22 @@ class RodauthMain < Rodauth::Rails::Auth
|
|
|
56
59
|
# ==> Emails
|
|
57
60
|
# Use a custom mailer for delivering authentication emails.
|
|
58
61
|
create_reset_password_email do
|
|
59
|
-
RodauthMailer.reset_password(account_id, reset_password_key_value)
|
|
62
|
+
RodauthMailer.reset_password(*self.class.configuration_name, account_id, reset_password_key_value)
|
|
60
63
|
end
|
|
61
64
|
create_verify_account_email do
|
|
62
|
-
RodauthMailer.verify_account(account_id, verify_account_key_value)
|
|
65
|
+
RodauthMailer.verify_account(*self.class.configuration_name, account_id, verify_account_key_value)
|
|
63
66
|
end
|
|
64
67
|
create_verify_login_change_email do |_login|
|
|
65
|
-
RodauthMailer.verify_login_change(
|
|
68
|
+
RodauthMailer.verify_login_change(*self.class.configuration_name, account_id, verify_login_change_key_value)
|
|
66
69
|
end
|
|
67
70
|
create_password_changed_email do
|
|
68
|
-
RodauthMailer.password_changed(account_id)
|
|
71
|
+
RodauthMailer.password_changed(*self.class.configuration_name, account_id)
|
|
69
72
|
end
|
|
70
73
|
# create_email_auth_email do
|
|
71
|
-
# RodauthMailer.email_auth(account_id, email_auth_key_value)
|
|
74
|
+
# RodauthMailer.email_auth(*self.class.configuration_name, account_id, email_auth_key_value)
|
|
72
75
|
# end
|
|
73
76
|
# create_unlock_account_email do
|
|
74
|
-
# RodauthMailer.unlock_account(account_id, unlock_account_key_value)
|
|
77
|
+
# RodauthMailer.unlock_account(*self.class.configuration_name, account_id, unlock_account_key_value)
|
|
75
78
|
# end
|
|
76
79
|
send_email do |email|
|
|
77
80
|
# queue email delivery on the mailer after the transaction commits
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
<% content_for :title, rodauth.confirm_password_page_title %>
|
|
2
|
-
|
|
3
1
|
<%= form_with url: rodauth.confirm_password_path, method: :post, data: { turbo: false } do |form| %>
|
|
4
2
|
<div class="form-group mb-3">
|
|
5
3
|
<%= form.label "password", rodauth.password_label, class: "form-label" %>
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
<% content_for :title, rodauth.otp_setup_page_title %>
|
|
2
|
-
|
|
3
1
|
<%= form_with url: rodauth.otp_setup_path, method: :post, data: { turbo: false } do |form| %>
|
|
4
2
|
<%= form.hidden_field rodauth.otp_setup_param, value: rodauth.otp_user_key, id: "otp-key" %>
|
|
5
3
|
<%= form.hidden_field rodauth.otp_setup_raw_param, value: rodauth.otp_key, id: "otp-hmac-secret" if rodauth.otp_keys_use_hmac? %>
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
<% content_for :title, rodauth.recovery_auth_page_title %>
|
|
2
|
-
|
|
3
1
|
<%= form_with url: rodauth.recovery_auth_path, method: :post, data: { turbo: false } do |form| %>
|
|
4
2
|
<div class="form-group mb-3">
|
|
5
3
|
<%= form.label "recovery-code", rodauth.recovery_codes_label, class: "form-label" %>
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
<% content_for :title, rodauth.verify_login_change_page_title %>
|
|
2
|
-
|
|
3
1
|
<%= form_with url: rodauth.verify_login_change_path, method: :post, data: { turbo: false } do |form| %>
|
|
4
2
|
<div class="form-group mb-3">
|
|
5
3
|
<%= form.submit rodauth.verify_login_change_button, class: "btn btn-primary" %>
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
<% content_for :title, rodauth.webauthn_auth_page_title %>
|
|
2
|
-
|
|
3
1
|
<% cred = rodauth.webauth_credential_options_for_get %>
|
|
4
2
|
|
|
5
3
|
<%= form_with url: rodauth.webauthn_auth_form_path, method: :post, id: "webauthn-auth-form", data: { credential_options: cred.as_json.to_json, turbo: false } do |form| %>
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
<% content_for :title, rodauth.webauthn_remove_page_title %>
|
|
2
|
-
|
|
3
1
|
<%= form_with url: rodauth.webauthn_remove_path, method: :post, id: "webauthn-remove-form", data: { turbo: false } do |form| %>
|
|
4
2
|
<% if rodauth.two_factor_modifications_require_password? %>
|
|
5
3
|
<div class="form-group mb-3">
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
<% content_for :title, rodauth.webauthn_setup_page_title %>
|
|
2
|
-
|
|
3
1
|
<% cred = rodauth.new_webauthn_credential %>
|
|
4
2
|
|
|
5
3
|
<%= form_with url: rodauth.webauthn_setup_path, method: :post, id: "webauthn-setup-form", data: { credential_options: cred.as_json.to_json, turbo: false } do |form| %>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Someone with an account has requested their login be changed to this email address:
|
|
2
2
|
|
|
3
|
-
Old email: <%= @
|
|
3
|
+
Old email: <%= @account.email %>
|
|
4
4
|
|
|
5
|
-
New email: <%= @
|
|
5
|
+
New email: <%= @new_email %>
|
|
6
6
|
|
|
7
7
|
If you did not request this login change, please ignore this message. If you
|
|
8
8
|
requested this login change, please go to
|
|
@@ -8,7 +8,8 @@ module Rodauth
|
|
|
8
8
|
|
|
9
9
|
# Renders templates with layout. First tries to render a user-defined
|
|
10
10
|
# template, otherwise falls back to Rodauth's template.
|
|
11
|
-
def view(page,
|
|
11
|
+
def view(page, title)
|
|
12
|
+
set_title(title)
|
|
12
13
|
rails_render(action: page.tr("-", "_"), layout: true) ||
|
|
13
14
|
rails_render(html: super.html_safe, layout: true, formats: :html)
|
|
14
15
|
end
|
|
@@ -50,6 +51,12 @@ module Rodauth
|
|
|
50
51
|
html = html.gsub(/<form(.+)>/, '<form\1 data-turbo="false">') if meth == :view
|
|
51
52
|
html
|
|
52
53
|
end
|
|
54
|
+
|
|
55
|
+
def set_title(title)
|
|
56
|
+
if title_instance_variable
|
|
57
|
+
rails_controller_instance.instance_variable_set(title_instance_variable, title)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
53
60
|
end
|
|
54
61
|
end
|
|
55
62
|
end
|
|
@@ -11,7 +11,6 @@ module Rodauth
|
|
|
11
11
|
require "rodauth/rails/feature/email"
|
|
12
12
|
require "rodauth/rails/feature/instrumentation"
|
|
13
13
|
require "rodauth/rails/feature/internal_request"
|
|
14
|
-
require "rodauth/rails/feature/associations"
|
|
15
14
|
|
|
16
15
|
include Rodauth::Rails::Feature::Base
|
|
17
16
|
include Rodauth::Rails::Feature::Callbacks
|
|
@@ -20,6 +19,5 @@ module Rodauth
|
|
|
20
19
|
include Rodauth::Rails::Feature::Email
|
|
21
20
|
include Rodauth::Rails::Feature::Instrumentation
|
|
22
21
|
include Rodauth::Rails::Feature::InternalRequest
|
|
23
|
-
include Rodauth::Rails::Feature::Associations
|
|
24
22
|
end
|
|
25
23
|
end
|
data/lib/rodauth/rails/model.rb
CHANGED
|
@@ -1,101 +1,6 @@
|
|
|
1
1
|
module Rodauth
|
|
2
2
|
module Rails
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def initialize(auth_class, association_options: {})
|
|
7
|
-
@auth_class = auth_class
|
|
8
|
-
@association_options = association_options
|
|
9
|
-
|
|
10
|
-
define_methods
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def included(model)
|
|
14
|
-
fail Rodauth::Rails::Error, "must be an Active Record model" unless model < ActiveRecord::Base
|
|
15
|
-
|
|
16
|
-
define_associations(model)
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
private
|
|
20
|
-
|
|
21
|
-
def define_methods
|
|
22
|
-
rodauth = @auth_class.allocate.freeze
|
|
23
|
-
|
|
24
|
-
attr_reader :password
|
|
25
|
-
|
|
26
|
-
define_method(:password=) do |password|
|
|
27
|
-
@password = password
|
|
28
|
-
password_hash = rodauth.send(:password_hash, password) if password
|
|
29
|
-
set_password_hash(password_hash)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
define_method(:set_password_hash) do |password_hash|
|
|
33
|
-
if rodauth.account_password_hash_column
|
|
34
|
-
public_send(:"#{rodauth.account_password_hash_column}=", password_hash)
|
|
35
|
-
else
|
|
36
|
-
if password_hash
|
|
37
|
-
record = self.password_hash || build_password_hash
|
|
38
|
-
record.public_send(:"#{rodauth.password_hash_column}=", password_hash)
|
|
39
|
-
else
|
|
40
|
-
self.password_hash&.mark_for_destruction
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def define_associations(model)
|
|
47
|
-
define_password_hash_association(model) unless rodauth.account_password_hash_column
|
|
48
|
-
|
|
49
|
-
rodauth.associations.each do |association|
|
|
50
|
-
define_association(model, **association, type: ASSOCIATION_TYPES.fetch(association[:type]))
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def define_password_hash_association(model)
|
|
55
|
-
password_hash_id_column = rodauth.password_hash_id_column
|
|
56
|
-
scope = -> { select(password_hash_id_column) } if rodauth.send(:use_database_authentication_functions?)
|
|
57
|
-
|
|
58
|
-
define_association model,
|
|
59
|
-
type: :has_one,
|
|
60
|
-
name: :password_hash,
|
|
61
|
-
table: rodauth.password_hash_table,
|
|
62
|
-
foreign_key: password_hash_id_column,
|
|
63
|
-
scope: scope,
|
|
64
|
-
autosave: true
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def define_association(model, type:, name:, table:, foreign_key:, scope: nil, **options)
|
|
68
|
-
associated_model = Class.new(model.superclass)
|
|
69
|
-
associated_model.table_name = table
|
|
70
|
-
associated_model.belongs_to :account,
|
|
71
|
-
class_name: model.name,
|
|
72
|
-
foreign_key: foreign_key,
|
|
73
|
-
inverse_of: name
|
|
74
|
-
|
|
75
|
-
model.const_set(name.to_s.singularize.camelize, associated_model)
|
|
76
|
-
|
|
77
|
-
unless name == :authentication_audit_logs
|
|
78
|
-
dependent = type == :has_many ? :delete_all : :delete
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
model.public_send type, name, scope,
|
|
82
|
-
class_name: associated_model.name,
|
|
83
|
-
foreign_key: foreign_key,
|
|
84
|
-
dependent: dependent,
|
|
85
|
-
inverse_of: :account,
|
|
86
|
-
**options,
|
|
87
|
-
**association_options(name)
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def association_options(name)
|
|
91
|
-
options = @association_options
|
|
92
|
-
options = options.call(name) if options.respond_to?(:call)
|
|
93
|
-
options || {}
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def rodauth
|
|
97
|
-
@auth_class.allocate
|
|
98
|
-
end
|
|
99
|
-
end
|
|
3
|
+
Model = Rodauth::Model
|
|
4
|
+
deprecate_constant :Model
|
|
100
5
|
end
|
|
101
6
|
end
|
data/lib/rodauth/rails.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "rodauth/rails/version"
|
|
2
2
|
require "rodauth/rails/railtie"
|
|
3
|
+
require "rodauth/model"
|
|
3
4
|
|
|
4
5
|
module Rodauth
|
|
5
6
|
module Rails
|
|
@@ -15,7 +16,7 @@ module Rodauth
|
|
|
15
16
|
@middleware = true
|
|
16
17
|
|
|
17
18
|
class << self
|
|
18
|
-
def rodauth(name = nil,
|
|
19
|
+
def rodauth(name = nil, account: nil, **options)
|
|
19
20
|
auth_class = app.rodauth!(name)
|
|
20
21
|
|
|
21
22
|
unless auth_class.features.include?(:internal_request)
|
|
@@ -43,7 +44,7 @@ module Rodauth
|
|
|
43
44
|
end
|
|
44
45
|
|
|
45
46
|
def model(name = nil, **options)
|
|
46
|
-
Rodauth::
|
|
47
|
+
Rodauth::Model.new(app.rodauth!(name), **options)
|
|
47
48
|
end
|
|
48
49
|
|
|
49
50
|
# routing constraint that requires authentication
|
data/rodauth-rails.gemspec
CHANGED
|
@@ -20,6 +20,7 @@ Gem::Specification.new do |spec|
|
|
|
20
20
|
spec.add_dependency "rodauth", "~> 2.23"
|
|
21
21
|
spec.add_dependency "roda", "~> 3.55"
|
|
22
22
|
spec.add_dependency "sequel-activerecord_connection", "~> 1.1"
|
|
23
|
+
spec.add_dependency "rodauth-model", "~> 0.2"
|
|
23
24
|
spec.add_dependency "tilt"
|
|
24
25
|
spec.add_dependency "bcrypt"
|
|
25
26
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rodauth-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Janko Marohnić
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2022-
|
|
11
|
+
date: 2022-06-11 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: railties
|
|
@@ -72,6 +72,20 @@ dependencies:
|
|
|
72
72
|
- - "~>"
|
|
73
73
|
- !ruby/object:Gem::Version
|
|
74
74
|
version: '1.1'
|
|
75
|
+
- !ruby/object:Gem::Dependency
|
|
76
|
+
name: rodauth-model
|
|
77
|
+
requirement: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0.2'
|
|
82
|
+
type: :runtime
|
|
83
|
+
prerelease: false
|
|
84
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0.2'
|
|
75
89
|
- !ruby/object:Gem::Dependency
|
|
76
90
|
name: tilt
|
|
77
91
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -264,7 +278,6 @@ files:
|
|
|
264
278
|
- lib/rodauth/rails/auth.rb
|
|
265
279
|
- lib/rodauth/rails/controller_methods.rb
|
|
266
280
|
- lib/rodauth/rails/feature.rb
|
|
267
|
-
- lib/rodauth/rails/feature/associations.rb
|
|
268
281
|
- lib/rodauth/rails/feature/base.rb
|
|
269
282
|
- lib/rodauth/rails/feature/callbacks.rb
|
|
270
283
|
- lib/rodauth/rails/feature/csrf.rb
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
module Rodauth
|
|
2
|
-
module Rails
|
|
3
|
-
module Feature
|
|
4
|
-
module Associations
|
|
5
|
-
def associations
|
|
6
|
-
list = []
|
|
7
|
-
|
|
8
|
-
features.each do |feature|
|
|
9
|
-
case feature
|
|
10
|
-
when :remember
|
|
11
|
-
list << { name: :remember_key, type: :one, table: remember_table, foreign_key: remember_id_column }
|
|
12
|
-
when :verify_account
|
|
13
|
-
list << { name: :verification_key, type: :one, table: verify_account_table, foreign_key: verify_account_id_column }
|
|
14
|
-
when :reset_password
|
|
15
|
-
list << { name: :password_reset_key, type: :one, table: reset_password_table, foreign_key: reset_password_id_column }
|
|
16
|
-
when :verify_login_change
|
|
17
|
-
list << { name: :login_change_key, type: :one, table: verify_login_change_table, foreign_key: verify_login_change_id_column }
|
|
18
|
-
when :lockout
|
|
19
|
-
list << { name: :lockout, type: :one, table: account_lockouts_table, foreign_key: account_lockouts_id_column }
|
|
20
|
-
list << { name: :login_failure, type: :one, table: account_login_failures_table, foreign_key: account_login_failures_id_column }
|
|
21
|
-
when :email_auth
|
|
22
|
-
list << { name: :email_auth_key, type: :one, table: email_auth_table, foreign_key: email_auth_id_column }
|
|
23
|
-
when :account_expiration
|
|
24
|
-
list << { name: :activity_time, type: :one, table: account_activity_table, foreign_key: account_activity_id_column }
|
|
25
|
-
when :active_sessions
|
|
26
|
-
list << { name: :active_session_keys, type: :many, table: active_sessions_table, foreign_key: active_sessions_account_id_column }
|
|
27
|
-
when :audit_logging
|
|
28
|
-
list << { name: :authentication_audit_logs, type: :many, table: audit_logging_table, foreign_key: audit_logging_account_id_column }
|
|
29
|
-
when :disallow_password_reuse
|
|
30
|
-
list << { name: :previous_password_hashes, type: :many, table: previous_password_hash_table, foreign_key: previous_password_account_id_column }
|
|
31
|
-
when :jwt_refresh
|
|
32
|
-
list << { name: :jwt_refresh_keys, type: :many, table: jwt_refresh_token_table, foreign_key: jwt_refresh_token_account_id_column }
|
|
33
|
-
when :password_expiration
|
|
34
|
-
list << { name: :password_change_time, type: :one, table: password_expiration_table, foreign_key: password_expiration_id_column }
|
|
35
|
-
when :single_session
|
|
36
|
-
list << { name: :session_key, type: :one, table: single_session_table, foreign_key: single_session_id_column }
|
|
37
|
-
when :otp
|
|
38
|
-
list << { name: :otp_key, type: :one, table: otp_keys_table, foreign_key: otp_keys_id_column }
|
|
39
|
-
when :sms_codes
|
|
40
|
-
list << { name: :sms_code, type: :one, table: sms_codes_table, foreign_key: sms_id_column }
|
|
41
|
-
when :recovery_codes
|
|
42
|
-
list << { name: :recovery_codes, type: :many, table: recovery_codes_table, foreign_key: recovery_codes_id_column }
|
|
43
|
-
when :webauthn
|
|
44
|
-
list << { name: :webauthn_user_id, type: :one, table: webauthn_user_ids_table, foreign_key: webauthn_user_ids_account_id_column }
|
|
45
|
-
list << { name: :webauthn_keys, type: :many, table: webauthn_keys_table, foreign_key: webauthn_keys_account_id_column }
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
list
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
end
|