trek 2.5.0 → 2.6.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/.node-version +1 -1
- data/AGENTS.md +7 -4
- data/CHANGELOG.md +37 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +16 -3
- data/Makefile +2 -0
- data/app/components/trek/dashboard/tile_component/tile_component.css +9 -0
- data/app/components/trek/dashboard/tile_component/tile_component.html.slim +4 -2
- data/app/components/trek/dashboard/tile_component.rb +12 -2
- data/app/components/trek/form/content_editor_component/content_editor_component.css +5 -0
- data/app/components/trek/form/content_editor_component/content_editor_component.html.slim +19 -93
- data/app/components/trek/form/content_editor_component/content_editor_component.js +94 -251
- data/app/components/trek/form/content_editor_component/extensions/block_view.js +40 -0
- data/app/components/trek/form/content_editor_component/extensions/image_block.js +28 -26
- data/app/components/trek/form/content_editor_component/extensions/prompts_block.js +15 -26
- data/app/components/trek/form/content_editor_component/extensions/table_of_contents.js +1 -3
- data/app/components/trek/form/content_editor_component.rb +15 -0
- data/app/components/trek/form/image_field_component/image_field_component.css +40 -1
- data/app/components/trek/form/image_field_component/image_field_component.html.slim +0 -9
- data/app/components/trek/form/image_field_component.rb +36 -9
- data/app/content/trek/content/cleaner.rb +53 -0
- data/app/content/trek/content/document.rb +2 -2
- data/app/content/trek/content/prompts_block.rb +1 -2
- data/app/content/trek/content.rb +6 -0
- data/app/controllers/concerns/trek/reorderable.rb +24 -6
- data/app/controllers/trek/csp_reports_controller.rb +14 -1
- data/app/controllers/trek/panels/prompts_controller.rb +1 -1
- data/app/controllers/trek/resource_controller.rb +5 -5
- data/app/jobs/trek/purge_expired_sessions_job.rb +4 -3
- data/app/models/concerns/trek/contentable.rb +15 -1
- data/app/models/concerns/trek/users/sign_in_code.rb +70 -0
- data/app/models/concerns/trek/users/sign_in_lockout.rb +4 -2
- data/app/views/trek/panels/links/_form.html.slim +2 -4
- data/app/views/trek/panels/prompts/create.turbo_stream.slim +0 -1
- data/config/locales/trek.en.yml +26 -11
- data/config/locales/trek.fr.yml +26 -11
- data/docs/.vitepress/config.mjs +4 -1
- data/docs/background-jobs.md +69 -0
- data/docs/content-editor.md +219 -0
- data/docs/customize.md +2 -0
- data/docs/deploy.md +2 -2
- data/docs/getting-started.md +1 -1
- data/docs/index.md +1 -1
- data/docs/reference/components.md +2 -2
- data/docs/reference/concerns/contentable.md +10 -4
- data/docs/reference/concerns/formattable.md +1 -1
- data/docs/reference/environment-variables.md +1 -0
- data/docs/reference/formatters.md +2 -30
- data/docs/reference/forms.md +1 -1
- data/docs/reference/generators/audit-log.md +2 -2
- data/docs/reference/generators/install.md +3 -2
- data/docs/reference/generators/public-accounts.md +141 -0
- data/docs/reference/generators/security-headers.md +1 -1
- data/docs/reference/index.md +2 -1
- data/docs/reference/models/user.md +3 -1
- data/docs/what-is-trek.md +1 -1
- data/lib/generators/trek/audit_log_generator.rb +54 -0
- data/lib/generators/trek/install/admin/users_generator.rb +6 -1
- data/lib/generators/trek/install/audit_log_generator.rb +9 -45
- data/lib/generators/trek/install/good_job_generator.rb +161 -0
- data/lib/generators/trek/install/model_specs_generator.rb +3 -3
- data/lib/generators/trek/install/ruby_generator.rb +1 -1
- data/lib/generators/trek/install/scalingo_generator.rb +7 -45
- data/lib/generators/trek/install_generator.rb +15 -0
- data/lib/generators/trek/public_accounts_generator.rb +267 -0
- data/lib/generators/trek/scalingo_generator.rb +55 -0
- data/lib/generators/trek/templates/.gitlab-ci.yml +1 -1
- data/lib/generators/trek/templates/controllers/admin/password_renewals_controller.rb +11 -0
- data/lib/generators/trek/templates/controllers/admin/user_sessions_controller.rb +1 -1
- data/lib/generators/trek/templates/controllers/admin/users_controller.rb +0 -8
- data/lib/generators/trek/templates/initializers/content_security_policy.rb +3 -2
- data/lib/generators/trek/templates/initializers/good_job.rb.tt +38 -0
- data/lib/generators/trek/templates/jobs/mail_delivery_job.rb +6 -0
- data/lib/generators/trek/templates/layouts/admin.html.slim +5 -0
- data/lib/generators/trek/templates/mailers/admin_mailer.rb +4 -2
- data/lib/generators/trek/templates/package.json.tt +1 -1
- data/lib/generators/trek/templates/public_accounts/api/authentication.rb.tt +76 -0
- data/lib/generators/trek/templates/public_accounts/api/controllers/accounts_controller.rb.tt +22 -0
- data/lib/generators/trek/templates/public_accounts/api/controllers/active_sessions_controller.rb.tt +21 -0
- data/lib/generators/trek/templates/public_accounts/api/controllers/base_controller.rb.tt +71 -0
- data/lib/generators/trek/templates/public_accounts/api/controllers/email_changes_controller.rb.tt +55 -0
- data/lib/generators/trek/templates/public_accounts/api/controllers/email_confirmations_controller.rb.tt +19 -0
- data/lib/generators/trek/templates/public_accounts/api/controllers/password_resets_controller.rb.tt +28 -0
- data/lib/generators/trek/templates/public_accounts/api/controllers/passwords_controller.rb.tt +17 -0
- data/lib/generators/trek/templates/public_accounts/api/controllers/registrations_controller.rb.tt +17 -0
- data/lib/generators/trek/templates/public_accounts/api/controllers/sessions_controller.rb.tt +45 -0
- data/lib/generators/trek/templates/public_accounts/api/controllers/sign_in_codes_controller.rb.tt +15 -0
- data/lib/generators/trek/templates/public_accounts/jobs/sign_in_code_job.rb.tt +13 -0
- data/lib/generators/trek/templates/public_accounts/locales/accounts.en.yml.tt +205 -0
- data/lib/generators/trek/templates/public_accounts/locales/accounts.fr.yml.tt +205 -0
- data/lib/generators/trek/templates/public_accounts/mailers/account_mailer.rb.tt +85 -0
- data/lib/generators/trek/templates/public_accounts/migrations/create_accounts.rb.tt +46 -0
- data/lib/generators/trek/templates/public_accounts/models/account/session.rb.tt +47 -0
- data/lib/generators/trek/templates/public_accounts/models/account/sign_in_code.rb.tt +8 -0
- data/lib/generators/trek/templates/public_accounts/models/account.rb.tt +73 -0
- data/lib/generators/trek/templates/public_accounts/spec/api/accounts_spec.rb.tt +90 -0
- data/lib/generators/trek/templates/public_accounts/spec/api/sign_ins_spec.rb.tt +158 -0
- data/lib/generators/trek/templates/public_accounts/spec/models/account/sign_in_code_spec.rb.tt +56 -0
- data/lib/generators/trek/templates/public_accounts/spec/models/account_spec.rb.tt +109 -0
- data/lib/generators/trek/templates/public_accounts/spec/web/accounts_spec.rb.tt +126 -0
- data/lib/generators/trek/templates/public_accounts/spec/web/authentication_spec.rb.tt +43 -0
- data/lib/generators/trek/templates/public_accounts/spec/web/sign_ins_spec.rb.tt +215 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/email_change.en.html.slim.tt +10 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/email_change.en.text.erb.tt +8 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/email_change.fr.html.slim.tt +13 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/email_change.fr.text.erb.tt +8 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/email_change_code.en.html.slim.tt +9 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/email_change_code.en.text.erb.tt +11 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/email_change_code.fr.html.slim.tt +11 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/email_change_code.fr.text.erb.tt +11 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/email_confirmation.en.html.slim.tt +10 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/email_confirmation.en.text.erb.tt +8 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/email_confirmation.fr.html.slim.tt +10 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/email_confirmation.fr.text.erb.tt +8 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/password_reset.en.html.slim.tt +10 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/password_reset.en.text.erb.tt +8 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/password_reset.fr.html.slim.tt +13 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/password_reset.fr.text.erb.tt +8 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_code.en.html.slim.tt +9 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_code.en.text.erb.tt +11 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_code.fr.html.slim.tt +9 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_code.fr.text.erb.tt +11 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_locked.en.html.slim.tt +23 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_locked.en.text.erb.tt +14 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_locked.fr.html.slim.tt +24 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_locked.fr.text.erb.tt +14 -0
- data/lib/generators/trek/templates/public_accounts/web/authentication.rb.tt +97 -0
- data/lib/generators/trek/templates/public_accounts/web/controllers/accounts_controller.rb.tt +33 -0
- data/lib/generators/trek/templates/public_accounts/web/controllers/active_sessions_controller.rb.tt +15 -0
- data/lib/generators/trek/templates/public_accounts/web/controllers/base_controller.rb.tt +52 -0
- data/lib/generators/trek/templates/public_accounts/web/controllers/email_changes_controller.rb.tt +85 -0
- data/lib/generators/trek/templates/public_accounts/web/controllers/email_confirmations_controller.rb.tt +19 -0
- data/lib/generators/trek/templates/public_accounts/web/controllers/password_resets_controller.rb.tt +41 -0
- data/lib/generators/trek/templates/public_accounts/web/controllers/passwords_controller.rb.tt +23 -0
- data/lib/generators/trek/templates/public_accounts/web/controllers/registrations_controller.rb.tt +24 -0
- data/lib/generators/trek/templates/public_accounts/web/controllers/sessions_controller.rb.tt +78 -0
- data/lib/generators/trek/templates/public_accounts/web/controllers/sign_in_codes_controller.rb.tt +27 -0
- data/lib/generators/trek/templates/public_accounts/web/views/_flash.html.slim.tt +5 -0
- data/lib/generators/trek/templates/public_accounts/web/views/accounts/show.html.slim.tt +52 -0
- data/lib/generators/trek/templates/public_accounts/web/views/email_changes/edit.html.slim.tt +17 -0
- data/lib/generators/trek/templates/public_accounts/web/views/email_changes/new.html.slim.tt +15 -0
- data/lib/generators/trek/templates/public_accounts/web/views/password_resets/edit.html.slim.tt +26 -0
- data/lib/generators/trek/templates/public_accounts/web/views/password_resets/new.html.slim.tt +15 -0
- data/lib/generators/trek/templates/public_accounts/web/views/passwords/edit.html.slim.tt +25 -0
- data/lib/generators/trek/templates/public_accounts/web/views/registrations/new.html.slim.tt +28 -0
- data/lib/generators/trek/templates/public_accounts/web/views/sessions/new.html.slim.tt +38 -0
- data/lib/generators/trek/templates/public_accounts/web/views/sign_in_codes/new.html.slim.tt +17 -0
- data/lib/generators/trek/templates/spec/jobs/mail_delivery_job_spec.rb +11 -0
- data/lib/generators/trek/templates/spec/mailers/admin_mailer_spec.rb +10 -0
- data/lib/generators/trek/templates/spec/requests/admin/fragments_spec.rb +1 -1
- data/lib/generators/trek/templates/spec/requests/admin/good_job_spec.rb +34 -0
- data/lib/generators/trek/templates/spec/requests/admin/pages_spec.rb +1 -1
- data/lib/generators/trek/templates/spec/requests/admin/panels_spec.rb +16 -0
- data/lib/generators/trek/templates/spec/requests/admin/password_renewal_spec.rb +2 -2
- data/lib/generators/trek/templates/spec/requests/admin/sign_in_lockout_spec.rb +1 -1
- data/lib/generators/trek/templates/spec/requests/admin/user_sessions_spec.rb +2 -2
- data/lib/generators/trek/templates/views/admin/users/index.html.slim +1 -1
- data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.en.html.slim +1 -1
- data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.en.text.erb +1 -1
- data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.fr.html.slim +1 -1
- data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.fr.text.erb +1 -1
- data/lib/trek/csp_reports.rb +22 -4
- data/lib/trek/typography_formatter.rb +35 -4
- data/lib/trek/version.rb +1 -1
- data/package.json +12 -8
- data/trek.gemspec +1 -1
- data/vitest.config.mjs +24 -0
- data/yarn.lock +1004 -288
- metadata +90 -3
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
class <%= class_name %>Mailer < ApplicationMailer
|
|
2
|
+
include ActionView::Helpers::DateHelper
|
|
3
|
+
<% if code? -%>
|
|
4
|
+
|
|
5
|
+
# The code to sign in with: to an email that may have no account yet.
|
|
6
|
+
def sign_in_code(email, code)
|
|
7
|
+
@code = code
|
|
8
|
+
@validity = distance_of_time_in_words(0, <%= class_name %>::SignInCode.validity)
|
|
9
|
+
|
|
10
|
+
mail to: email
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# The code confirming a new email address for an account, sent to that address.
|
|
14
|
+
def email_change_code(email, code)
|
|
15
|
+
@code = code
|
|
16
|
+
@validity = distance_of_time_in_words(0, <%= class_name %>::SignInCode.validity)
|
|
17
|
+
|
|
18
|
+
mail to: email
|
|
19
|
+
end
|
|
20
|
+
<% else -%>
|
|
21
|
+
|
|
22
|
+
# The link confirming the email given on signing up. Its token is made here, in the job sending
|
|
23
|
+
# the email, rather than in the request.
|
|
24
|
+
def email_confirmation(<%= file_name %>)
|
|
25
|
+
@<%= file_name %> = <%= file_name %>
|
|
26
|
+
@url = link_to_confirm(<%= file_name %>.generate_token_for(:email_confirmation))
|
|
27
|
+
@validity = distance_of_time_in_words(0, <%= class_name %>.token_definitions[:email_confirmation].expires_in)
|
|
28
|
+
|
|
29
|
+
mail to: <%= file_name %>.email
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The link to choose a new password; nothing is sent to an email without an account.
|
|
33
|
+
def password_reset(<%= file_name %>)
|
|
34
|
+
@<%= file_name %> = <%= file_name %>
|
|
35
|
+
@url = link_to_reset(<%= file_name %>.password_reset_token)
|
|
36
|
+
@validity = distance_of_time_in_words(0, <%= class_name %>.token_definitions[:password_reset].expires_in)
|
|
37
|
+
|
|
38
|
+
mail to: <%= file_name %>.email
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The link confirming a new email (unconfirmed_email), sent to that address.
|
|
42
|
+
def email_change(<%= file_name %>)
|
|
43
|
+
@<%= file_name %> = <%= file_name %>
|
|
44
|
+
@url = link_to_change_email(<%= file_name %>.generate_token_for(:email_change))
|
|
45
|
+
@validity = distance_of_time_in_words(0, <%= class_name %>.token_definitions[:email_change].expires_in)
|
|
46
|
+
|
|
47
|
+
mail to: <%= file_name %>.unconfirmed_email
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Signing in is locked after failed tries in a row (Trek::Users::SignInLockout): the
|
|
51
|
+
# <%= human_name.downcase %> waits, resets the password, or learns someone may be guessing it.
|
|
52
|
+
def sign_in_locked(<%= file_name %>)
|
|
53
|
+
@<%= file_name %> = <%= file_name %>
|
|
54
|
+
<% if web? -%>
|
|
55
|
+
@reset_url = new_<%= route_prefix %>_password_reset_url
|
|
56
|
+
<% end -%>
|
|
57
|
+
|
|
58
|
+
mail to: <%= file_name %>.email
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
<% if model_only? -%>
|
|
64
|
+
# The app's own sign-in handles these links: a page of the site, or the mobile app opening them (iOS
|
|
65
|
+
# universal links, Android app links). They point at the app's domain, or at <%= accounts_path.upcase %>_APP_LINKS_URL.
|
|
66
|
+
<% elsif api? -%>
|
|
67
|
+
# The mobile app opens these links (iOS universal links, Android app links) and sends their token
|
|
68
|
+
# to the API. They point at the app's domain, or at <%= accounts_path.upcase %>_APP_LINKS_URL.
|
|
69
|
+
<% end -%>
|
|
70
|
+
<% unless web? -%>
|
|
71
|
+
def link_to_confirm(token) = app_link("account/confirm", token)
|
|
72
|
+
def link_to_reset(token) = app_link("password-reset", token)
|
|
73
|
+
def link_to_change_email(token) = app_link("account/email", token)
|
|
74
|
+
|
|
75
|
+
def app_link(path, token)
|
|
76
|
+
base = ENV.fetch("<%= accounts_path.upcase %>_APP_LINKS_URL") { "https://#{default_url_options.fetch(:host)}<%= "/#{options[:path]}" if options[:path].present? %>" }
|
|
77
|
+
"#{base}/#{path}?#{{ token: }.to_query}"
|
|
78
|
+
end
|
|
79
|
+
<% else -%>
|
|
80
|
+
def link_to_confirm(token) = <%= route_prefix %>_email_confirmation_url(token:)
|
|
81
|
+
def link_to_reset(token) = edit_<%= route_prefix %>_password_reset_url(token:)
|
|
82
|
+
def link_to_change_email(token) = <%= route_prefix %>_email_change_url(token:)
|
|
83
|
+
<% end -%>
|
|
84
|
+
<% end -%>
|
|
85
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
class TrekCreate<%= class_name.pluralize %> < <%= migration_class_name %>
|
|
2
|
+
def change
|
|
3
|
+
create_table :<%= table_name %> do |t|
|
|
4
|
+
t.string :email, null: false, index: { unique: true }
|
|
5
|
+
t.string :name
|
|
6
|
+
<% if password? -%>
|
|
7
|
+
t.string :password_digest, null: false
|
|
8
|
+
t.integer :failed_sign_in_count, null: false, default: 0
|
|
9
|
+
t.datetime :sign_in_locked_until
|
|
10
|
+
# When the email was confirmed by its link; nil until then
|
|
11
|
+
t.datetime :confirmed_at
|
|
12
|
+
# A new email, until its link confirms it
|
|
13
|
+
t.string :unconfirmed_email
|
|
14
|
+
<% end -%>
|
|
15
|
+
# The last request, at most every 5 minutes, e.g. to delete the accounts nobody uses
|
|
16
|
+
t.datetime :last_activity_at, index: true
|
|
17
|
+
|
|
18
|
+
t.timestamps
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
create_table :<%= file_name %>_sessions do |t|
|
|
22
|
+
t.belongs_to :<%= file_name %>, null: false, foreign_key: true
|
|
23
|
+
t.string :user_agent
|
|
24
|
+
t.string :ip_address
|
|
25
|
+
# The host name of the IP address (Trek::ReverseDns)
|
|
26
|
+
t.string :ip_hostname
|
|
27
|
+
t.datetime :last_activity_at, null: false
|
|
28
|
+
|
|
29
|
+
t.timestamps
|
|
30
|
+
end
|
|
31
|
+
<% if code? -%>
|
|
32
|
+
|
|
33
|
+
create_table :<%= file_name %>_sign_in_codes do |t|
|
|
34
|
+
t.string :email, null: false
|
|
35
|
+
t.string :purpose, null: false
|
|
36
|
+
t.belongs_to :<%= file_name %>, foreign_key: true
|
|
37
|
+
t.string :code_digest, null: false
|
|
38
|
+
t.integer :attempts, null: false, default: 0
|
|
39
|
+
t.datetime :expires_at, null: false, index: true
|
|
40
|
+
|
|
41
|
+
t.datetime :created_at, null: false
|
|
42
|
+
end
|
|
43
|
+
add_index :<%= file_name %>_sign_in_codes, %i[email purpose]
|
|
44
|
+
<% end -%>
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<% if api? -%>
|
|
2
|
+
# A device of the mobile app signed in as the <%= human_name.downcase %>, found through the JWT it sends
|
|
3
|
+
# (Authorization: Bearer). The token only names the session: signing out deletes the session, and the
|
|
4
|
+
# token stops working with it.
|
|
5
|
+
<% elsif model_only? -%>
|
|
6
|
+
# A signed-in <%= human_name.downcase %>: the app's own sign-in creates the session, finds it on each request
|
|
7
|
+
# (Current.<%= file_name %>_session) and deletes it on signing out.
|
|
8
|
+
<% else -%>
|
|
9
|
+
# A browser signed in as the <%= human_name.downcase %>, found through a signed <%= file_name %>_session_id cookie.
|
|
10
|
+
<% end -%>
|
|
11
|
+
class <%= class_name %>::Session < ApplicationRecord
|
|
12
|
+
# Expires after idle_timeout without a request, and lifetime after signing in; longer than the admin's.
|
|
13
|
+
include Trek::Users::SessionLifetime
|
|
14
|
+
self.idle_timeout = 30.days
|
|
15
|
+
self.lifetime = 1.year
|
|
16
|
+
|
|
17
|
+
# Changing the password or deleting the account needs a sign-in this recent.
|
|
18
|
+
RECENT = 15.minutes
|
|
19
|
+
|
|
20
|
+
belongs_to :<%= file_name %>
|
|
21
|
+
|
|
22
|
+
def recent?
|
|
23
|
+
created_at > RECENT.ago
|
|
24
|
+
end
|
|
25
|
+
<% if api? -%>
|
|
26
|
+
|
|
27
|
+
# Signed with a key of its own, derived from the app's secret_key_base.
|
|
28
|
+
TOKEN_ALGORITHM = "HS256".freeze
|
|
29
|
+
|
|
30
|
+
def self.token_key
|
|
31
|
+
Rails.application.key_generator.generate_key("<%= accounts_path %>/session_tokens")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The session the token names, unless the token is forged or past the session's lifetime.
|
|
35
|
+
def self.from_token(token)
|
|
36
|
+
payload, = JWT.decode(token, token_key, true, algorithm: TOKEN_ALGORITHM)
|
|
37
|
+
find_by(id: payload["sub"])
|
|
38
|
+
rescue JWT::DecodeError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The JWT the app keeps and sends with each request; it expires with the session's lifetime.
|
|
43
|
+
def token
|
|
44
|
+
JWT.encode({ sub: id, iat: created_at.to_i, exp: (created_at + lifetime).to_i }, self.class.token_key, TOKEN_ALGORITHM)
|
|
45
|
+
end
|
|
46
|
+
<% end -%>
|
|
47
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# A code emailed to sign in, or to confirm a new email address (Trek::Users::SignInCode): kept by
|
|
2
|
+
# email, as one is sent whether or not the email has an account.
|
|
3
|
+
class <%= class_name %>::SignInCode < ApplicationRecord
|
|
4
|
+
include Trek::Users::SignInCode
|
|
5
|
+
|
|
6
|
+
# The account changing its email to this one; nil when signing in.
|
|
7
|
+
belongs_to :<%= file_name %>, optional: true
|
|
8
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<% if code? -%>
|
|
2
|
+
# A <%= human_name.downcase %>'s account, apart from the admin's User: it signs in with a code emailed to it
|
|
3
|
+
# (<%= class_name %>::SignInCode), which creates the account the first time.
|
|
4
|
+
<% else -%>
|
|
5
|
+
# A <%= human_name.downcase %>'s account, apart from the admin's User: it signs up and in with a password, its
|
|
6
|
+
# email confirmed by a link (confirmed_at), and a forgotten password reset by one.
|
|
7
|
+
<% end -%>
|
|
8
|
+
class <%= class_name %> < ApplicationRecord
|
|
9
|
+
<% if password? -%>
|
|
10
|
+
# Required, at most 72 bytes; password_reset_token (15 minutes, used up by a new password).
|
|
11
|
+
has_secure_password
|
|
12
|
+
# The password rules (length, complexity, common passwords…); set password_min_length to change them.
|
|
13
|
+
include Trek::Users::PasswordPolicy
|
|
14
|
+
# Locks signing in for a while after 5 failures in a row, longer on each further failure.
|
|
15
|
+
include Trek::Users::SignInLockout
|
|
16
|
+
self.sign_in_lockout_mailer = "<%= class_name %>Mailer"
|
|
17
|
+
|
|
18
|
+
# The link confirming the email, used up once it is confirmed or changed.
|
|
19
|
+
generates_token_for :email_confirmation, expires_in: 1.day do
|
|
20
|
+
[email, confirmed_at]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The link confirming a new email (unconfirmed_email), used up once it is changed.
|
|
24
|
+
generates_token_for :email_change, expires_in: 1.hour do
|
|
25
|
+
[email, unconfirmed_email]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
<% end -%>
|
|
29
|
+
has_many :sessions, dependent: :delete_all
|
|
30
|
+
<% if code? -%>
|
|
31
|
+
has_many :sign_in_codes, dependent: :delete_all
|
|
32
|
+
<% end -%>
|
|
33
|
+
|
|
34
|
+
# Also applies to lookups (find_by(email:)), so signing in works whatever the case typed.
|
|
35
|
+
normalizes :email<% if password? %>, :unconfirmed_email<% end %>, with: ->(email) { email.strip.downcase }
|
|
36
|
+
|
|
37
|
+
validates :email, presence: true, uniqueness: { case_sensitive: false }, email: { mode: :strict }
|
|
38
|
+
<% if password? -%>
|
|
39
|
+
validates :unconfirmed_email, email: { mode: :strict }, allow_nil: true
|
|
40
|
+
<% end -%>
|
|
41
|
+
|
|
42
|
+
# The accounts nobody used since `time`, e.g. to delete them: they can only be <%= human_name.pluralize.downcase %>.
|
|
43
|
+
scope :inactive_since, ->(time) { where(last_activity_at: ...time).or(where(last_activity_at: nil, created_at: ...time)) }
|
|
44
|
+
<% if code? -%>
|
|
45
|
+
|
|
46
|
+
# The account of `email`, created if it has none: the code it was sent proved the email is theirs.
|
|
47
|
+
def self.for_verified_email!(email)
|
|
48
|
+
find_by(email:) || create!(email:)
|
|
49
|
+
rescue ActiveRecord::RecordNotUnique
|
|
50
|
+
# Created in parallel, e.g. a double submit
|
|
51
|
+
find_by!(email:)
|
|
52
|
+
end
|
|
53
|
+
<% else -%>
|
|
54
|
+
|
|
55
|
+
def confirmed?
|
|
56
|
+
confirmed_at.present?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def confirm_email!
|
|
60
|
+
update!(confirmed_at: Time.current)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The new email once its link is followed, confirmed by it. False when another account took it
|
|
64
|
+
# in the meantime.
|
|
65
|
+
def change_email!
|
|
66
|
+
update(email: unconfirmed_email, unconfirmed_email: nil, confirmed_at: Time.current)
|
|
67
|
+
end
|
|
68
|
+
<% end -%>
|
|
69
|
+
|
|
70
|
+
def to_s
|
|
71
|
+
name.presence || email
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
require "rails_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe "<%= human_name.pluralize %>' accounts through the API", type: :request do
|
|
4
|
+
include ActiveSupport::Testing::TimeHelpers
|
|
5
|
+
|
|
6
|
+
<% if code? -%>
|
|
7
|
+
let(:<%= file_name %>) { <%= class_name %>.create!(email: "ada@example.com") }
|
|
8
|
+
<% else -%>
|
|
9
|
+
let(:<%= file_name %>) { <%= class_name %>.create!(email: "ada@example.com", password: "correct horse battery staple") }
|
|
10
|
+
<% end -%>
|
|
11
|
+
let(:token) { sign_in(<%= file_name %>) }
|
|
12
|
+
let(:headers) { { "Authorization" => "Bearer #{token}" } }
|
|
13
|
+
|
|
14
|
+
<% if code? -%>
|
|
15
|
+
def emailed_code
|
|
16
|
+
ActionMailer::Base.deliveries.last.text_part.body.to_s[/\b\d{6}\b/]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def sign_in(<%= file_name %>)
|
|
20
|
+
perform_enqueued_jobs { post <%= route_prefix %>_sign_in_path, params: { email: <%= file_name %>.email }, as: :json }
|
|
21
|
+
post <%= route_prefix %>_sign_in_code_path, params: { email: <%= file_name %>.email, otp: emailed_code }, as: :json
|
|
22
|
+
response.parsed_body["token"]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def change_email_to(email)
|
|
26
|
+
perform_enqueued_jobs { post <%= route_prefix %>_email_change_path, params: { email: }, headers:, as: :json }
|
|
27
|
+
patch <%= route_prefix %>_email_change_path, params: { email:, otp: emailed_code }, headers:, as: :json
|
|
28
|
+
end
|
|
29
|
+
<% else -%>
|
|
30
|
+
def emailed_token
|
|
31
|
+
CGI.unescape(ActionMailer::Base.deliveries.last.text_part.body.to_s[/token=([^\s&]+)/, 1].to_s)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def sign_in(<%= file_name %>)
|
|
35
|
+
post <%= route_prefix %>_sign_in_path, params: { email: <%= file_name %>.email, password: "correct horse battery staple" }, as: :json
|
|
36
|
+
response.parsed_body["token"]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def change_email_to(email)
|
|
40
|
+
perform_enqueued_jobs { post <%= route_prefix %>_email_change_path, params: { email: }, headers:, as: :json }
|
|
41
|
+
patch <%= route_prefix %>_email_change_path, params: { token: emailed_token }, as: :json
|
|
42
|
+
end
|
|
43
|
+
<% end -%>
|
|
44
|
+
|
|
45
|
+
it "changes the name" do
|
|
46
|
+
patch <%= route_prefix %>_account_path, params: { name: "Ada Lovelace" }, headers:, as: :json
|
|
47
|
+
|
|
48
|
+
expect(response.parsed_body["name"]).to eq("Ada Lovelace")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it "changes the email once the new one is confirmed", :aggregate_failures do
|
|
52
|
+
change_email_to("ada@lovelace.org")
|
|
53
|
+
|
|
54
|
+
expect(ActionMailer::Base.deliveries.last.to).to eq(["ada@lovelace.org"])
|
|
55
|
+
expect(<%= file_name %>.reload.email).to eq("ada@lovelace.org")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it "lists the signed-in devices, and signs the others out", :aggregate_failures do
|
|
59
|
+
other = <%= file_name %>.sessions.create!
|
|
60
|
+
get <%= route_prefix %>_active_sessions_path, headers: headers
|
|
61
|
+
|
|
62
|
+
expect(response.parsed_body.pluck("current")).to contain_exactly(true, false)
|
|
63
|
+
delete others_<%= route_prefix %>_active_sessions_path, headers: headers
|
|
64
|
+
expect(<%= class_name %>::Session.exists?(other.id)).to be(false)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it "deletes the account after a recent sign-in", :aggregate_failures do
|
|
68
|
+
headers
|
|
69
|
+
|
|
70
|
+
expect { delete <%= route_prefix %>_account_path, headers: }.to change(<%= class_name %>, :count).by(-1)
|
|
71
|
+
expect(response).to have_http_status(:no_content)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it "asks to sign in again before deleting the account", :aggregate_failures do
|
|
75
|
+
headers
|
|
76
|
+
travel <%= class_name %>::Session::RECENT + 1.minute
|
|
77
|
+
|
|
78
|
+
expect { delete <%= route_prefix %>_account_path, headers: }.not_to change(<%= class_name %>, :count)
|
|
79
|
+
expect(response.parsed_body["error"]).to eq("recent_sign_in_needed")
|
|
80
|
+
end
|
|
81
|
+
<% if password? -%>
|
|
82
|
+
|
|
83
|
+
it "changes the password after a recent sign-in", :aggregate_failures do
|
|
84
|
+
patch <%= route_prefix %>_password_path, params: { password: "a brand new password 1!" }, headers:, as: :json
|
|
85
|
+
|
|
86
|
+
expect(response).to have_http_status(:no_content)
|
|
87
|
+
expect(<%= file_name %>.reload.authenticate("a brand new password 1!")).to be_truthy
|
|
88
|
+
end
|
|
89
|
+
<% end -%>
|
|
90
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
require "rails_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe "<%= human_name.pluralize %>' sign-ins through the API", type: :request do
|
|
4
|
+
include ActiveSupport::Testing::TimeHelpers
|
|
5
|
+
|
|
6
|
+
<% if code? -%>
|
|
7
|
+
def emailed_code
|
|
8
|
+
ActionMailer::Base.deliveries.last.text_part.body.to_s[/\b\d{6}\b/]
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# The response's status
|
|
12
|
+
def ask_for_code(email)
|
|
13
|
+
perform_enqueued_jobs { post <%= route_prefix %>_sign_in_path, params: { email: }, as: :json }
|
|
14
|
+
response.status
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def sign_in(email)
|
|
18
|
+
ask_for_code(email)
|
|
19
|
+
post <%= route_prefix %>_sign_in_code_path, params: { email:, otp: emailed_code }, as: :json
|
|
20
|
+
response.parsed_body["token"]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it "emails a code to an email without an account, with the same answer as for an account", :aggregate_failures do
|
|
24
|
+
<%= class_name %>.create!(email: "ada@example.com")
|
|
25
|
+
|
|
26
|
+
expect(ask_for_code("grace@example.com")).to eq(ask_for_code("ada@example.com")).and eq(202)
|
|
27
|
+
expect(ActionMailer::Base.deliveries.flat_map(&:to)).to eq(%w[grace@example.com ada@example.com])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it "refuses an invalid email", :aggregate_failures do
|
|
31
|
+
post <%= route_prefix %>_sign_in_path, params: { email: "not an email" }, as: :json
|
|
32
|
+
|
|
33
|
+
expect(response).to have_http_status(:unprocessable_content)
|
|
34
|
+
expect(response.parsed_body["error"]).to eq("invalid_email")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it "creates the account and answers with a token", :aggregate_failures do
|
|
38
|
+
token = nil
|
|
39
|
+
|
|
40
|
+
expect { token = sign_in("grace@example.com") }.to change(<%= class_name %>, :count).by(1)
|
|
41
|
+
expect(response).to have_http_status(:created)
|
|
42
|
+
expect(token).to be_present
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it "refuses a wrong code", :aggregate_failures do
|
|
46
|
+
ask_for_code("ada@example.com")
|
|
47
|
+
post <%= route_prefix %>_sign_in_code_path, params: { email: "ada@example.com", otp: "000000" }, as: :json
|
|
48
|
+
|
|
49
|
+
expect(response).to have_http_status(:unauthorized)
|
|
50
|
+
expect(response.parsed_body["error"]).to eq("invalid_code")
|
|
51
|
+
end
|
|
52
|
+
<% else -%>
|
|
53
|
+
let(:password) { "correct horse battery staple" }
|
|
54
|
+
let!(:<%= file_name %>) { <%= class_name %>.create!(email: "ada@example.com", password:) }
|
|
55
|
+
|
|
56
|
+
def emailed_token
|
|
57
|
+
CGI.unescape(ActionMailer::Base.deliveries.last.text_part.body.to_s[/token=([^\s&]+)/, 1].to_s)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def sign_in(email, password: self.password)
|
|
61
|
+
post <%= route_prefix %>_sign_in_path, params: { email:, password: }, as: :json
|
|
62
|
+
response.parsed_body["token"]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "signs up and in, answering with a token, and emails a link to confirm the email", :aggregate_failures do
|
|
66
|
+
perform_enqueued_jobs { post <%= route_prefix %>_sign_up_path, params: { email: "grace@example.com", password: }, as: :json }
|
|
67
|
+
|
|
68
|
+
expect(response).to have_http_status(:created)
|
|
69
|
+
expect(response.parsed_body["token"]).to be_present
|
|
70
|
+
expect(ActionMailer::Base.deliveries.last.to).to eq(["grace@example.com"])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it "confirms the email with the token of the emailed link" do
|
|
74
|
+
perform_enqueued_jobs { post <%= route_prefix %>_sign_up_path, params: { email: "grace@example.com", password: }, as: :json }
|
|
75
|
+
|
|
76
|
+
patch <%= route_prefix %>_email_confirmation_path, params: { token: emailed_token }, as: :json
|
|
77
|
+
|
|
78
|
+
expect(<%= class_name %>.find_by(email: "grace@example.com")).to be_confirmed
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "answers with a token" do
|
|
82
|
+
expect(sign_in(<%= file_name %>.email)).to be_present
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "refuses a wrong password", :aggregate_failures do
|
|
86
|
+
sign_in(<%= file_name %>.email, password: "wrong horse battery staple")
|
|
87
|
+
|
|
88
|
+
expect(response).to have_http_status(:unauthorized)
|
|
89
|
+
expect(response.parsed_body["error"]).to eq("invalid_password")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it "resets the password with the token of the emailed link", :aggregate_failures do
|
|
93
|
+
perform_enqueued_jobs { post <%= route_prefix %>_password_reset_path, params: { email: <%= file_name %>.email }, as: :json }
|
|
94
|
+
expect(response).to have_http_status(:accepted)
|
|
95
|
+
|
|
96
|
+
patch <%= route_prefix %>_password_reset_path, params: { token: emailed_token, password: "a brand new password 1!" }, as: :json
|
|
97
|
+
expect(<%= file_name %>.reload.authenticate("a brand new password 1!")).to be_truthy
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it "answers a password reset the same for an unknown email, and sends nothing", :aggregate_failures do
|
|
101
|
+
perform_enqueued_jobs { post <%= route_prefix %>_password_reset_path, params: { email: "nobody@example.com" }, as: :json }
|
|
102
|
+
|
|
103
|
+
expect(response).to have_http_status(:accepted)
|
|
104
|
+
expect(ActionMailer::Base.deliveries).to be_empty
|
|
105
|
+
end
|
|
106
|
+
<% end -%>
|
|
107
|
+
|
|
108
|
+
<% if audit_log? -%>
|
|
109
|
+
it "records the sign-in in the audit log" do
|
|
110
|
+
sign_in("ada@example.com")
|
|
111
|
+
|
|
112
|
+
expect(AuditLog.where(action: "<%= controllers_path %>/sessions#create", actor: <%= class_name %>.last)).to exist
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
<% end -%>
|
|
116
|
+
it "uses the token for the next requests" do
|
|
117
|
+
token = sign_in("ada@example.com")
|
|
118
|
+
|
|
119
|
+
get <%= route_prefix %>_account_path, headers: { "Authorization" => "Bearer #{token}" }
|
|
120
|
+
|
|
121
|
+
expect(response.parsed_body["email"]).to eq("ada@example.com")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "refuses a request without a token, or with a forged one", :aggregate_failures do
|
|
125
|
+
get <%= route_prefix %>_account_path
|
|
126
|
+
expect(response).to have_http_status(:unauthorized)
|
|
127
|
+
|
|
128
|
+
token = sign_in("ada@example.com")
|
|
129
|
+
get <%= route_prefix %>_account_path, headers: { "Authorization" => "Bearer #{token.chop}x" }
|
|
130
|
+
expect(response).to have_http_status(:unauthorized)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it "ends the token's session when signing out" do
|
|
134
|
+
token = sign_in("ada@example.com")
|
|
135
|
+
|
|
136
|
+
delete <%= route_prefix %>_session_path, headers: { "Authorization" => "Bearer #{token}" }
|
|
137
|
+
get <%= route_prefix %>_account_path, headers: { "Authorization" => "Bearer #{token}" }
|
|
138
|
+
|
|
139
|
+
expect(response).to have_http_status(:unauthorized)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it "refuses the token after the session's idle timeout" do
|
|
143
|
+
token = sign_in("ada@example.com")
|
|
144
|
+
|
|
145
|
+
travel <%= class_name %>::Session.idle_timeout + 1.minute
|
|
146
|
+
get <%= route_prefix %>_account_path, headers: { "Authorization" => "Bearer #{token}" }
|
|
147
|
+
|
|
148
|
+
expect(response).to have_http_status(:unauthorized)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it "refuses a token signed with another key" do
|
|
152
|
+
forged = JWT.encode({ sub: <%= class_name %>::Session.maximum(:id).to_i + 1 }, "wrong key", "HS256")
|
|
153
|
+
|
|
154
|
+
get <%= route_prefix %>_account_path, headers: { "Authorization" => "Bearer #{forged}" }
|
|
155
|
+
|
|
156
|
+
expect(response).to have_http_status(:unauthorized)
|
|
157
|
+
end
|
|
158
|
+
end
|
data/lib/generators/trek/templates/public_accounts/spec/models/account/sign_in_code_spec.rb.tt
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
require "rails_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe <%= class_name %>::SignInCode, type: :model do
|
|
4
|
+
include ActiveSupport::Testing::TimeHelpers
|
|
5
|
+
|
|
6
|
+
it "keeps a digest of the code, not the code", :aggregate_failures do
|
|
7
|
+
sign_in_code = described_class.issue("ada@example.com")
|
|
8
|
+
|
|
9
|
+
expect(sign_in_code.code).to match(/\A\d{6}\z/)
|
|
10
|
+
expect(described_class.find(sign_in_code.id).code_digest).not_to include(sign_in_code.code)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it "signs in once with the right code", :aggregate_failures do
|
|
14
|
+
code = described_class.issue("ada@example.com").code
|
|
15
|
+
|
|
16
|
+
expect(described_class.redeem("Ada@example.com", code)).to be_present
|
|
17
|
+
expect(described_class.redeem("ada@example.com", code)).to be_nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it "accepts the code with spaces" do
|
|
21
|
+
code = described_class.issue("ada@example.com").code
|
|
22
|
+
|
|
23
|
+
expect(described_class.redeem("ada@example.com", "#{code[0, 3]} #{code[3..]}")).to be_present
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it "replaces the email's earlier code" do
|
|
27
|
+
first = described_class.issue("ada@example.com").code
|
|
28
|
+
described_class.issue("ada@example.com")
|
|
29
|
+
|
|
30
|
+
expect(described_class.redeem("ada@example.com", first)).to be_nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it "stops working after its validity" do
|
|
34
|
+
code = described_class.issue("ada@example.com").code
|
|
35
|
+
|
|
36
|
+
travel described_class.validity + 1.second do
|
|
37
|
+
expect(described_class.redeem("ada@example.com", code)).to be_nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "stops working after too many wrong tries, even with the right code" do
|
|
42
|
+
code = described_class.issue("ada@example.com").code
|
|
43
|
+
wrong = code.succ.rjust(6, "0")[-6..]
|
|
44
|
+
described_class.max_attempts.times { described_class.redeem("ada@example.com", wrong) }
|
|
45
|
+
|
|
46
|
+
expect(described_class.redeem("ada@example.com", code)).to be_nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it "doesn't sign in with a code confirming a new email", :aggregate_failures do
|
|
50
|
+
<%= file_name %> = <%= class_name %>.create!(email: "ada@example.com")
|
|
51
|
+
code = described_class.issue("new@example.com", purpose: "email_change", <%= file_name %>:).code
|
|
52
|
+
|
|
53
|
+
expect(described_class.redeem("new@example.com", code)).to be_nil
|
|
54
|
+
expect(described_class.redeem("new@example.com", code, purpose: "email_change", <%= file_name %>:)).to be_present
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
require "rails_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe <%= class_name %>, type: :model do
|
|
4
|
+
include ActiveSupport::Testing::TimeHelpers
|
|
5
|
+
|
|
6
|
+
<% if password? -%>
|
|
7
|
+
let(:password) { "correct horse battery staple" }
|
|
8
|
+
|
|
9
|
+
<% end -%>
|
|
10
|
+
def create_<%= file_name %>(email, **attributes)
|
|
11
|
+
described_class.create!(email:<% if password? %>, password:<% end %>, **attributes)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it "keeps emails in lower case, without spaces" do
|
|
15
|
+
expect(create_<%= file_name %>(" Ada@Example.com ").email).to eq("ada@example.com")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it "refuses an email another account has, whatever its case" do
|
|
19
|
+
create_<%= file_name %>("ada@example.com")
|
|
20
|
+
|
|
21
|
+
expect(described_class.new(email: "ADA@example.com")).not_to be_valid
|
|
22
|
+
end
|
|
23
|
+
<% if code? -%>
|
|
24
|
+
|
|
25
|
+
it "creates the account of a verified email once" do
|
|
26
|
+
first = described_class.for_verified_email!("ada@example.com")
|
|
27
|
+
|
|
28
|
+
expect(described_class.for_verified_email!("Ada@example.com")).to eq(first)
|
|
29
|
+
end
|
|
30
|
+
<% end -%>
|
|
31
|
+
|
|
32
|
+
it "finds the accounts nobody used since a date", :aggregate_failures do
|
|
33
|
+
used = create_<%= file_name %>("used@example.com", last_activity_at: 1.day.ago)
|
|
34
|
+
idle = create_<%= file_name %>("idle@example.com", last_activity_at: 2.years.ago)
|
|
35
|
+
never = travel_to(2.years.ago) { create_<%= file_name %>("never@example.com") }
|
|
36
|
+
|
|
37
|
+
expect(described_class.inactive_since(1.year.ago)).to contain_exactly(idle, never)
|
|
38
|
+
expect(described_class.inactive_since(1.year.ago)).not_to include(used)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "deletes its sessions with it" do
|
|
42
|
+
<%= file_name %> = create_<%= file_name %>("ada@example.com")
|
|
43
|
+
<%= file_name %>.sessions.create!
|
|
44
|
+
|
|
45
|
+
expect { <%= file_name %>.destroy }.to change(<%= class_name %>::Session, :count).by(-1)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "loses its expired sessions to the daily purge" do
|
|
49
|
+
<%= file_name %> = create_<%= file_name %>("ada@example.com")
|
|
50
|
+
<%= file_name %>.sessions.create!(created_at: 2.years.ago, last_activity_at: 2.years.ago)
|
|
51
|
+
current = <%= file_name %>.sessions.create!
|
|
52
|
+
|
|
53
|
+
Trek::PurgeExpiredSessionsJob.perform_now("<%= class_name %>::Session")
|
|
54
|
+
|
|
55
|
+
expect(<%= file_name %>.sessions.reload).to contain_exactly(current)
|
|
56
|
+
end
|
|
57
|
+
<% if password? -%>
|
|
58
|
+
|
|
59
|
+
describe "password" do
|
|
60
|
+
it "is required" do
|
|
61
|
+
expect(described_class.new(email: "ada@example.com")).not_to be_valid
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "follows the password rules" do
|
|
65
|
+
expect(described_class.new(email: "ada@example.com", password: "short")).not_to be_valid
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
describe "email confirmation" do
|
|
70
|
+
let(:<%= file_name %>) { create_<%= file_name %>("ada@example.com") }
|
|
71
|
+
|
|
72
|
+
it "is pending after signing up" do
|
|
73
|
+
expect(<%= file_name %>).not_to be_confirmed
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "finds the account by its link, once", :aggregate_failures do
|
|
77
|
+
token = <%= file_name %>.generate_token_for(:email_confirmation)
|
|
78
|
+
|
|
79
|
+
expect(described_class.find_by_token_for(:email_confirmation, token)).to eq(<%= file_name %>)
|
|
80
|
+
<%= file_name %>.confirm_email!
|
|
81
|
+
expect(described_class.find_by_token_for(:email_confirmation, token)).to be_nil
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
describe "email change" do
|
|
86
|
+
let(:<%= file_name %>) { create_<%= file_name %>("ada@example.com", unconfirmed_email: "ada@lovelace.org") }
|
|
87
|
+
|
|
88
|
+
it "takes the new email, confirmed", :aggregate_failures do
|
|
89
|
+
<%= file_name %>.change_email!
|
|
90
|
+
|
|
91
|
+
expect(<%= file_name %>.reload.email).to eq("ada@lovelace.org")
|
|
92
|
+
expect(<%= file_name %>).to be_confirmed
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it "keeps the email when another account has the new one" do
|
|
96
|
+
create_<%= file_name %>("ada@lovelace.org")
|
|
97
|
+
|
|
98
|
+
expect(<%= file_name %>.change_email!).to be(false)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "uses its link up once the email changed" do
|
|
102
|
+
token = <%= file_name %>.generate_token_for(:email_change)
|
|
103
|
+
<%= file_name %>.change_email!
|
|
104
|
+
|
|
105
|
+
expect(described_class.find_by_token_for(:email_change, token)).to be_nil
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
<% end -%>
|
|
109
|
+
end
|