masks 0.3.1 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
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>