masks 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/masks/application.css +1 -1
  3. data/app/assets/builds/masks/application.js +2153 -726
  4. data/app/assets/builds/masks/application.js.map +4 -4
  5. data/app/assets/javascripts/controllers/application.js +1 -1
  6. data/app/assets/javascripts/controllers/index.js +9 -0
  7. data/app/assets/javascripts/controllers/table_controller.js +15 -0
  8. data/app/assets/stylesheets/application.css +12 -4
  9. data/app/controllers/concerns/masks/controller.rb +1 -1
  10. data/app/controllers/masks/manage/actors_controller.rb +72 -1
  11. data/app/controllers/masks/manage/base_controller.rb +10 -2
  12. data/app/controllers/masks/manage/clients_controller.rb +84 -0
  13. data/app/controllers/masks/manage/dashboard_controller.rb +15 -0
  14. data/app/controllers/masks/manage/devices_controller.rb +19 -0
  15. data/app/controllers/masks/openid/authorizations_controller.rb +45 -0
  16. data/app/controllers/masks/openid/discoveries_controller.rb +55 -0
  17. data/app/controllers/masks/openid/tokens_controller.rb +45 -0
  18. data/app/controllers/masks/openid/userinfo_controller.rb +28 -0
  19. data/app/controllers/masks/sessions_controller.rb +1 -1
  20. data/app/models/concerns/masks/access.rb +2 -2
  21. data/app/models/masks/access/actor_password.rb +2 -1
  22. data/app/models/masks/access/actor_signup.rb +1 -2
  23. data/app/models/masks/credentials/access_token.rb +60 -0
  24. data/app/models/masks/credentials/key.rb +1 -1
  25. data/app/models/masks/credentials/return_to.rb +27 -0
  26. data/app/models/masks/mask.rb +12 -1
  27. data/app/models/masks/openid/authorization.rb +116 -0
  28. data/app/models/masks/openid/token.rb +56 -0
  29. data/app/models/masks/rails/actor.rb +23 -1
  30. data/app/models/masks/rails/openid/access_token.rb +55 -0
  31. data/app/models/masks/rails/openid/authorization.rb +45 -0
  32. data/app/models/masks/rails/openid/client.rb +186 -0
  33. data/app/models/masks/rails/openid/id_token.rb +43 -0
  34. data/app/models/masks/sessions/access.rb +2 -1
  35. data/app/resources/masks/session_resource.rb +1 -1
  36. data/app/views/layouts/masks/manage.html.erb +22 -5
  37. data/app/views/masks/actor_mailer/recover_credentials.html.erb +2 -3
  38. data/app/views/masks/actor_mailer/verify_email.html.erb +2 -3
  39. data/app/views/masks/actors/current.html.erb +7 -14
  40. data/app/views/masks/application/_header.html.erb +3 -4
  41. data/app/views/masks/backup_codes/new.html.erb +34 -20
  42. data/app/views/masks/emails/new.html.erb +14 -8
  43. data/app/views/masks/keys/new.html.erb +7 -7
  44. data/app/views/masks/manage/actors/index.html.erb +101 -37
  45. data/app/views/masks/manage/{actor → actors}/show.html.erb +63 -17
  46. data/app/views/masks/manage/clients/index.html.erb +102 -0
  47. data/app/views/masks/manage/clients/show.html.erb +156 -0
  48. data/app/views/masks/manage/dashboard/index.html.erb +10 -0
  49. data/app/views/masks/manage/devices/index.html.erb +47 -0
  50. data/app/views/masks/one_time_code/new.html.erb +41 -24
  51. data/app/views/masks/openid/authorizations/error.html.erb +23 -0
  52. data/app/views/masks/openid/authorizations/new.html.erb +46 -0
  53. data/app/views/masks/passwords/edit.html.erb +20 -7
  54. data/app/views/masks/recoveries/new.html.erb +2 -4
  55. data/app/views/masks/recoveries/password.html.erb +2 -3
  56. data/app/views/masks/sessions/new.html.erb +22 -23
  57. data/config/initializers/inflections.rb +5 -0
  58. data/config/locales/en.yml +23 -2
  59. data/config/routes.rb +40 -3
  60. data/db/migrate/20240329182422_support_openid.rb +64 -0
  61. data/lib/generators/masks/install/templates/masks.json +4 -1
  62. data/lib/masks/configuration.rb +22 -9
  63. data/lib/masks/version.rb +1 -1
  64. data/lib/masks.rb +1 -0
  65. data/lib/tasks/masks_tasks.rake +3 -2
  66. data/masks.json +47 -6
  67. metadata +59 -11
  68. data/app/assets/builds/application.css +0 -4764
  69. data/app/assets/builds/application.js +0 -8236
  70. data/app/assets/builds/application.js.map +0 -7
  71. data/app/controllers/masks/manage/actor_controller.rb +0 -35
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+ module Masks
3
+ module OpenID
4
+ # Manages authorizations for OpenID/OAuth2 requests.
5
+ class Authorization
6
+ attr_accessor :client, :scopes, :response, :response_type
7
+
8
+ class << self
9
+ def perform(env, **opts)
10
+ authorization = new(env, **opts)
11
+ authorization.perform
12
+ authorization
13
+ end
14
+ end
15
+
16
+ def initialize(env, **opts)
17
+ @env = env
18
+ @app =
19
+ Rack::OAuth2::Server::Authorize.new do |req, res|
20
+ @client =
21
+ session.config.model(:openid_client).find_by(key: req.client_id)
22
+
23
+ req.bad_request!(:client_id, "not found") unless @client
24
+
25
+ unless req.redirect_uri
26
+ req.invalid_request!('"redirect_uri" missing')
27
+ end
28
+
29
+ unless @client.redirect_uris.any?
30
+ @client.redirect_uris = [req.redirect_uri.to_s]
31
+ @client.valid? || req.invalid_request!('"redirect_uri" invalid')
32
+ end
33
+
34
+ res.redirect_uri = req.verify_redirect_uri!(@client.redirect_uris)
35
+
36
+ @scopes = req.scope & @client.scopes
37
+
38
+ if res.protocol_params_location == :fragment && req.nonce.blank?
39
+ req.invalid_request! "nonce required"
40
+ end
41
+
42
+ if @client.response_types.include?(
43
+ Array(req.response_type).collect(&:to_s).join(" ")
44
+ )
45
+ if actor
46
+ if opts[:approved] || client.auto_consent?
47
+ @client.save if @client.redirect_uris_changed?
48
+
49
+ approved! req, res
50
+ elsif opts.key?(:approved)
51
+ req.access_denied!
52
+ end
53
+ end
54
+ else
55
+ req.unsupported_response_type!
56
+ end
57
+ end
58
+ end
59
+
60
+ def session
61
+ @session ||= @env[Masks::Middleware::SESSION_KEY]
62
+ end
63
+
64
+ def actor
65
+ @actor ||= (session.actor if session.passed?)
66
+ end
67
+
68
+ def perform
69
+ @response = @app.call(@env)
70
+ end
71
+
72
+ def approved!(req, res)
73
+ response_types = Array(req.response_type)
74
+
75
+ if response_types.include? :code
76
+ authorization =
77
+ actor.openid_authorizations.create!(
78
+ openid_client: client,
79
+ redirect_uri: res.redirect_uri,
80
+ nonce: req.nonce,
81
+ scopes: @scopes
82
+ )
83
+
84
+ res.code = authorization.code
85
+ end
86
+
87
+ if response_types.include? :token
88
+ access_token =
89
+ actor.openid_access_tokens.create!(
90
+ openid_client: client,
91
+ scopes: @scopes
92
+ )
93
+
94
+ res.access_token = access_token.to_bearer_token
95
+ end
96
+
97
+ if response_types.include? :id_token
98
+ id_token =
99
+ actor.openid_id_tokens.create!(
100
+ openid_client: @client,
101
+ nonce: req.nonce
102
+ )
103
+
104
+ res.id_token =
105
+ id_token.to_jwt(
106
+ code: (res.respond_to?(:code) ? res.code : nil),
107
+ access_token:
108
+ (res.respond_to?(:access_token) ? res.access_token : nil)
109
+ )
110
+ end
111
+
112
+ res.approve!
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+ module Masks
3
+ module OpenID
4
+ # Implementation of the Token Endpoint in OIDC.
5
+ #
6
+ # Technically speaking, this conforms to the rack interface
7
+ # so it can be used directly for managing requests for access
8
+ # tokens.
9
+ class Token
10
+ attr_accessor :app
11
+
12
+ delegate :call, to: :app
13
+
14
+ def initialize
15
+ @app =
16
+ Rack::OAuth2::Server::Token.new do |req, res|
17
+ client =
18
+ Masks
19
+ .configuration
20
+ .model(:openid_client)
21
+ .find_by(key: req.client_id) || req.invalid_client!
22
+ client.secret == req.client_secret || req.invalid_client!
23
+ client.grant_types.include?(req.grant_type.to_s) ||
24
+ req.unsupported_grant_type!
25
+
26
+ case req.grant_type
27
+ when :client_credentials
28
+ res.access_token = client.access_tokens.create!.to_bearer_token
29
+ when :authorization_code
30
+ authorization =
31
+ client.authorizations.valid.where(code: req.code).first
32
+ unless authorization&.valid_redirect_uri?(req.redirect_uri)
33
+ req.invalid_grant!
34
+ end
35
+ access_token = authorization.access_token
36
+ res.access_token = access_token.to_bearer_token
37
+
38
+ if access_token.scope?("openid")
39
+ res.id_token =
40
+ access_token
41
+ .actor
42
+ .openid_id_tokens
43
+ .create!(
44
+ openid_client: access_token.openid_client,
45
+ nonce: authorization.nonce
46
+ )
47
+ .to_jwt
48
+ end
49
+ else
50
+ req.unsupported_grant_type!
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -32,6 +32,15 @@ module Masks
32
32
  has_many :keys,
33
33
  class_name: Masks.configuration.models[:key],
34
34
  autosave: true
35
+ has_many :openid_authorizations,
36
+ class_name: Masks.configuration.models[:openid_authorization],
37
+ autosave: true
38
+ has_many :openid_access_tokens,
39
+ class_name: Masks.configuration.models[:openid_access_token],
40
+ autosave: true
41
+ has_many :openid_id_tokens,
42
+ class_name: Masks.configuration.models[:openid_id_token],
43
+ autosave: true
35
44
 
36
45
  has_secure_password
37
46
 
@@ -41,7 +50,7 @@ module Masks
41
50
 
42
51
  before_validation :reset_version, unless: :version
43
52
 
44
- validates :nickname, uniqueness: { case_sensitive: false }
53
+ validates :nickname, presence: true, uniqueness: { case_sensitive: false }
45
54
  validates :totp_secret, presence: true, if: :totp_code
46
55
  validates :version, presence: true
47
56
  validate :validates_totp, if: :totp_code
@@ -52,6 +61,14 @@ module Masks
52
61
 
53
62
  serialize :backup_codes, coder: JSON
54
63
 
64
+ def to_param
65
+ nickname
66
+ end
67
+
68
+ def primary_email
69
+ emails.where(verified: true).first
70
+ end
71
+
55
72
  def email_addresses
56
73
  emails.pluck(:email)
57
74
  end
@@ -68,6 +85,11 @@ module Masks
68
85
  save!
69
86
  end
70
87
 
88
+ def logout!
89
+ reset_version
90
+ save!
91
+ end
92
+
71
93
  def totp_uri
72
94
  (totp || random_totp).provisioning_uri(nickname)
73
95
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+ module Masks
3
+ module Rails
4
+ module OpenID
5
+ class AccessToken < ApplicationRecord
6
+ include Masks::Scoped
7
+
8
+ self.table_name = "openid_access_tokens"
9
+
10
+ scope :valid, -> { where("expires_at >= ?", Time.now.utc) }
11
+
12
+ belongs_to :actor,
13
+ class_name: Masks.configuration.models[:actor],
14
+ optional: true
15
+ belongs_to :openid_client,
16
+ class_name: Masks.configuration.models[:openid_client]
17
+
18
+ serialize :scopes, coder: JSON
19
+
20
+ before_validation :generate_token
21
+
22
+ validates :openid_client, presence: true
23
+ validates :token, presence: true, uniqueness: true
24
+ validates :expires_at, presence: true
25
+
26
+ def scopes
27
+ value = self[:scopes]
28
+
29
+ return [] unless value
30
+
31
+ value & ((actor&.scopes || []) + openid_client.scopes)
32
+ end
33
+
34
+ def roles(*args, **opts)
35
+ (actor || openid_client).roles(*args, **opts)
36
+ end
37
+
38
+ def to_bearer_token
39
+ Rack::OAuth2::AccessToken::Bearer.new(
40
+ access_token: token,
41
+ expires_in: (expires_at - Time.now.utc).to_i
42
+ )
43
+ end
44
+
45
+ private
46
+
47
+ def generate_token
48
+ self.token ||= SecureRandom.uuid
49
+ self.expires_at ||= openid_client.token_expires_at
50
+ self.scopes ||= []
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+ module Masks
3
+ module Rails
4
+ module OpenID
5
+ class Authorization < ApplicationRecord
6
+ self.table_name = "openid_authorizations"
7
+
8
+ scope :valid, -> { where("expires_at >= ?", Time.now.utc) }
9
+
10
+ belongs_to :actor, class_name: Masks.configuration.models[:actor]
11
+ belongs_to :openid_client,
12
+ class_name: Masks.configuration.models[:openid_client]
13
+
14
+ serialize :scopes, coder: JSON
15
+
16
+ before_validation :generate_code
17
+
18
+ validates :actor, presence: true
19
+ validates :openid_client, presence: true
20
+ validates :code, presence: true, uniqueness: true
21
+ validates :expires_at, presence: true
22
+
23
+ def valid_redirect_uri?(uri)
24
+ uri == redirect_uri
25
+ end
26
+
27
+ def access_token
28
+ @access_token ||=
29
+ update_attribute!(:expires_at, Time.now) && generate_access_token!
30
+ end
31
+
32
+ def generate_access_token!
33
+ actor.openid_access_tokens.create!(openid_client:, scopes:)
34
+ end
35
+
36
+ private
37
+
38
+ def generate_code
39
+ self.code ||= SecureRandom.uuid
40
+ self.expires_at ||= openid_client.code_expires_at
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+ module Masks
3
+ module Rails
4
+ module OpenID
5
+ class Client < ApplicationRecord
6
+ include Masks::Scoped
7
+
8
+ self.table_name = "openid_clients"
9
+
10
+ validates :name, presence: true
11
+
12
+ after_initialize :generate_credentials
13
+ before_validation :generate_key, on: :create
14
+
15
+ serialize :scopes, coder: JSON
16
+ serialize :redirect_uris, coder: JSON
17
+ serialize :response_types, coder: JSON
18
+ serialize :grant_types, coder: JSON
19
+
20
+ validates :key, :secret, :scopes, presence: true
21
+ validates :key, uniqueness: true
22
+ validates :client_type,
23
+ inclusion: {
24
+ in: %w[public confidential]
25
+ },
26
+ presence: true
27
+ validates :subject_type,
28
+ inclusion: {
29
+ in: Masks.configuration.openid[:subject_types]
30
+ },
31
+ presence: true
32
+ validate :validate_expiries
33
+
34
+ has_many :access_tokens,
35
+ class_name: Masks.configuration.models[:openid_access_token],
36
+ inverse_of: :openid_client
37
+ has_many :authorizations,
38
+ class_name: Masks.configuration.models[:openid_authorization],
39
+ inverse_of: :openid_client
40
+ has_many :saved_roles,
41
+ class_name: Masks.configuration.models[:role],
42
+ autosave: true
43
+
44
+ def to_param
45
+ key
46
+ end
47
+
48
+ def response_types
49
+ case client_type
50
+ when "confidential"
51
+ ["code"]
52
+ when "public"
53
+ ["token", "id_token", "id_token token"]
54
+ end
55
+ end
56
+
57
+ def grant_types
58
+ case client_type
59
+ when "confidential"
60
+ %w[refresh_token authorization_code client_credentials]
61
+ else
62
+ []
63
+ end
64
+ end
65
+
66
+ def scopes
67
+ self[:scopes]
68
+ end
69
+
70
+ def roles(record, **opts)
71
+ case record
72
+ when Class, String
73
+ saved_roles.where(record_type: record.to_s, **opts).includes(
74
+ :record
75
+ )
76
+ else
77
+ saved_roles.where(record:, **opts)
78
+ end
79
+ end
80
+
81
+ def issuer
82
+ Masks::Engine.routes.url_helpers.openid_issuer_url(
83
+ id: key,
84
+ host: Masks.configuration.site_url
85
+ )
86
+ end
87
+
88
+ def kid
89
+ :default
90
+ end
91
+
92
+ def private_key
93
+ OpenSSL::PKey::RSA.new(rsa_private_key)
94
+ end
95
+
96
+ delegate :public_key, to: :private_key
97
+
98
+ def subject(actor)
99
+ case subject_type
100
+ when "nickname"
101
+ actor.nickname
102
+ else
103
+ Digest::SHA256.hexdigest(
104
+ [
105
+ sector_identifier,
106
+ actor.actor_id,
107
+ Masks.configuration.openid[:pairwise_salt]
108
+ ].join("/")
109
+ )
110
+ end
111
+ end
112
+
113
+ def audience
114
+ key
115
+ end
116
+
117
+ def auto_consent?
118
+ !consent
119
+ end
120
+
121
+ def pairwise_subject?
122
+ sector_identifier && subject_type == "pairwise"
123
+ end
124
+
125
+ def assign_scopes!(*scopes)
126
+ self.scopes = [*scopes, *self.scopes].uniq.compact
127
+ save!
128
+ end
129
+
130
+ def remove_scopes!(*scopes)
131
+ scopes.each { |scope| self.scopes.delete(scope) }
132
+
133
+ save!
134
+ end
135
+
136
+ def code_expires_at
137
+ Time.now + ChronicDuration.parse(code_expires_in)
138
+ end
139
+
140
+ def token_expires_at
141
+ Time.now + ChronicDuration.parse(token_expires_in)
142
+ end
143
+
144
+ def refresh_expires_at
145
+ Time.now + ChronicDuration.parse(refresh_expires_in)
146
+ end
147
+
148
+ private
149
+
150
+ def generate_credentials
151
+ self.secret ||= SecureRandom.uuid
152
+ self.client_type ||= "confidential"
153
+ self.subject_type ||= "nickname"
154
+ self.scopes ||= Masks.configuration.openid[:scopes] || []
155
+ self.rsa_private_key ||= OpenSSL::PKey::RSA.generate(2048).to_pem
156
+ self.sector_identifier ||=
157
+ URI.parse(Masks.configuration.site_url).host
158
+ self.code_expires_in ||= "5 minutes"
159
+ self.token_expires_in ||= "1 day"
160
+ self.refresh_expires_in ||= "1 week"
161
+ end
162
+
163
+ def generate_key
164
+ return unless name
165
+
166
+ key = name.parameterize
167
+ count = self.class.where("key LIKE ?", "#{key}%").count
168
+
169
+ self.key = count.positive? ? "#{key}-#{count + 1}" : key
170
+ end
171
+
172
+ def validate_expiries
173
+ %i[
174
+ code_expires_in
175
+ token_expires_in
176
+ refresh_expires_in
177
+ ].each do |param|
178
+ unless ChronicDuration.parse(send(param))
179
+ errors.add(param, :invalid)
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+ module Masks
3
+ module Rails
4
+ module OpenID
5
+ class IdToken < ApplicationRecord
6
+ self.table_name = "openid_id_tokens"
7
+
8
+ belongs_to :actor, class_name: Masks.configuration.models[:actor]
9
+ belongs_to :openid_client,
10
+ class_name: Masks.configuration.models[:openid_client]
11
+
12
+ def to_response_object(with = {})
13
+ subject =
14
+ if openid_client.pairwise_subject?
15
+ openid_client.subject_for(actor)
16
+ else
17
+ actor.actor_id
18
+ end
19
+
20
+ claims = {
21
+ sub: subject,
22
+ iss: openid_client.issuer,
23
+ aud: openid_client.audience,
24
+ exp: expires_at.to_i,
25
+ iat: created_at.to_i,
26
+ nonce:
27
+ }
28
+
29
+ id_token = OpenIDConnect::ResponseObject::IdToken.new(claims)
30
+ id_token.code = with[:code] if with[:code]
31
+ id_token.access_token = with[:access_token] if with[:access_token]
32
+ id_token
33
+ end
34
+
35
+ def to_jwt(with = {})
36
+ to_response_object(with).to_jwt(openid_client.private_key) do |jwt|
37
+ jwt.kid = openid_client.kid
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -19,7 +19,8 @@ module Masks
19
19
  def matches_mask?(mask)
20
20
  return false unless mask.access == name.to_s
21
21
 
22
- original.mask.access&.try(:include?, name.to_s) || original.mask.access
22
+ original.mask.access&.try(:include?, name.to_s) ||
23
+ original.mask.access == mask.access
23
24
  end
24
25
  end
25
26
  end
@@ -9,7 +9,7 @@ module Masks
9
9
  attribute :authorized, &:passed?
10
10
 
11
11
  attribute :actor do |session|
12
- ActorResource.new(session.actor).to_h
12
+ ActorResource.new(session.actor).to_h if session.actor
13
13
  end
14
14
  end
15
15
  end
@@ -9,12 +9,29 @@
9
9
  <%= javascript_include_tag "masks/application", "data-turbo-track": "reload", defer: true %>
10
10
  </head>
11
11
  <body class="h-full">
12
- <div class="navbar my-2 join box-border px-6">
13
- <div class="flex items-center gap-1 bg-accent rounded px-2 py-1.5 join-item">
14
- <%= image_tag "masks.png", class: 'w-5 h-5 rounded-full border-black border' %>
12
+ <div class="navbar bg-base-100 mb-6">
13
+ <div class="navbar-start">
14
+ <div class="dropdown">
15
+ <div tabindex="0" role="button" class="btn btn-ghost text-lg">
16
+ masks
17
+ </div>
18
+ <ul tabindex="0" class="menu dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52 gap-1">
19
+ <li><a class="<%= section == :dashboard ? 'active' : '' %>" href="<%= manage_path %>">Dashboard</a></li>
20
+ <li><a class="<%= section == :clients ? 'active' : '' %>" href="<%= manage_clients_path %>">Clients</a></li>
21
+ <li><a class="<%= section == :actors ? 'active' : '' %>" href="<%= manage_actors_path %>">Actors</a></li>
22
+ <li><a class="<%= section == :devices ? 'active' : '' %>" href="<%= manage_devices_path %>">Devices</a></li>
23
+ </ul>
24
+ </div>
15
25
  </div>
16
- <div class="flex items-center bg-base-100 rounded px-2 py-1.5 join-item">
17
- <a href="<%= actors_path %>" class="text-sm text-accent-content opacity-75 hover:opacity-100 hover:underline focus:underline font-mono">/manage</a>
26
+
27
+ <div class="navbar-end pr-2">
28
+ <a href="<%= manage_actor_path(current_actor) %>">
29
+ <div class="avatar placeholder">
30
+ <div class="bg-neutral text-neutral-content rounded-full w-10">
31
+ <span class="text-base"><%= current_actor.nickname.slice(0, 1).upcase %></span>
32
+ </div>
33
+ </div>
34
+ </a>
18
35
  </div>
19
36
  </div>
20
37
 
@@ -15,9 +15,8 @@
15
15
  </div>
16
16
  <a
17
17
  role="button"
18
- href="<%= recover_password_url(token: @recovery)%>"
19
- class="btn"
20
- >
18
+ href="<%= recover_password_url(token: @recovery) %>"
19
+ class="btn">
21
20
  <%= lucide_icon("rotate-cw", class: "stroke-warning") %>
22
21
  <%= t(".recover") %>
23
22
  </a>
@@ -16,9 +16,8 @@
16
16
  </div>
17
17
  <a
18
18
  role="button"
19
- href="<%= email_verify_url(email: @email, approve: true)%>"
20
- class="btn"
21
- >
19
+ href="<%= email_verify_url(email: @email, approve: true) %>"
20
+ class="btn">
22
21
  <%= lucide_icon("mail-check", class: "stroke-success") %>
23
22
  <%= t(".verify") %>
24
23
  </a>