api_keys 0.3.0 → 0.4.1

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/README.md +147 -38
  4. data/SECURITY.md +33 -0
  5. data/app/controllers/api_keys/application_controller.rb +92 -10
  6. data/app/controllers/api_keys/keys_controller.rb +40 -18
  7. data/app/views/api_keys/keys/_empty_state.html.erb +1 -1
  8. data/app/views/api_keys/keys/_form.html.erb +3 -3
  9. data/app/views/api_keys/keys/_key_actions.html.erb +3 -3
  10. data/app/views/api_keys/keys/_key_badges.html.erb +2 -2
  11. data/app/views/api_keys/keys/_key_row.html.erb +1 -1
  12. data/app/views/api_keys/keys/_key_status.html.erb +3 -3
  13. data/app/views/api_keys/keys/_keys_table.html.erb +1 -4
  14. data/app/views/api_keys/keys/_show_token.html.erb +5 -46
  15. data/app/views/api_keys/keys/_token_display.html.erb +3 -3
  16. data/app/views/api_keys/keys/index.html.erb +2 -2
  17. data/app/views/api_keys/keys/show.html.erb +2 -2
  18. data/app/views/api_keys/security/best_practices.html.erb +7 -7
  19. data/app/views/layouts/api_keys/application.html.erb +159 -12
  20. data/lib/api_keys/authentication.rb +39 -11
  21. data/lib/api_keys/configuration.rb +412 -24
  22. data/lib/api_keys/engine.rb +5 -20
  23. data/lib/api_keys/form_builder_extensions.rb +12 -2
  24. data/lib/api_keys/helpers/expiration_options.rb +11 -3
  25. data/lib/api_keys/helpers/token_session.rb +143 -8
  26. data/lib/api_keys/helpers/view_helpers.rb +5 -1
  27. data/lib/api_keys/jobs/callbacks_job.rb +10 -17
  28. data/lib/api_keys/jobs/update_stats_job.rb +27 -12
  29. data/lib/api_keys/models/api_key.rb +244 -25
  30. data/lib/api_keys/models/concerns/has_api_keys.rb +95 -32
  31. data/lib/api_keys/services/authenticator.rb +263 -118
  32. data/lib/api_keys/services/digestor.rb +76 -13
  33. data/lib/api_keys/services/token_generator.rb +41 -1
  34. data/lib/api_keys/tenant_resolution.rb +2 -4
  35. data/lib/api_keys/version.rb +1 -1
  36. data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
  37. data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +24 -0
  38. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +2 -14
  39. data/lib/generators/api_keys/templates/initializer.rb +54 -17
  40. metadata +16 -16
  41. data/.simplecov +0 -36
  42. data/AGENTS.md +0 -5
  43. data/Appraisals +0 -17
  44. data/CLAUDE.md +0 -5
  45. data/Rakefile +0 -37
  46. data/context7.json +0 -4
  47. data/gemfiles/rails_7.2.gemfile +0 -21
  48. data/gemfiles/rails_8.0.gemfile +0 -21
  49. data/gemfiles/rails_8.1.gemfile +0 -21
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module ApiKeys
4
6
  # Base controller for the ApiKeys engine.
5
7
  # Inherits from the host application's configured controller
6
8
  # (defaults to ::ApplicationController).
7
9
  # Includes common engine functionality.
8
- class ApplicationController < ApiKeys::Engine.config.parent_controller.constantize
10
+ class ApplicationController < ApiKeys.configuration.parent_controller_class
9
11
  # Protect from forgery if the parent controller does
10
12
  # This ensures CSRF protection behaves consistently with the host app.
11
13
  protect_from_forgery with: :exception if respond_to?(:protect_from_forgery)
@@ -13,28 +15,95 @@ module ApiKeys
13
15
  # Include the main controller concern which bundles authentication and tenant resolution
14
16
  include ApiKeys::Controller
15
17
 
18
+ # The dashboard renders credential material, so give it an enforcing,
19
+ # self-contained CSP even when the host application has not configured one.
20
+ # Preserve any host directives we do not explicitly tighten.
21
+ #
22
+ # The policy is selected by `ApiKeys.configuration.dashboard_content_security_policy`
23
+ # and resolved per request, so host applications can relax or disable it from
24
+ # an initializer. See `apply_api_keys_content_security_policy` below.
25
+ before_action :apply_api_keys_content_security_policy
26
+
16
27
  # Ensure the owner is authenticated for all actions within this engine
17
28
  # This uses the configured authentication method (defaults to authenticate_user!)
18
29
  before_action :authenticate_api_keys_owner!
30
+ after_action :set_api_keys_security_headers
19
31
 
20
32
  private
21
33
 
34
+ # Declares the dashboard's Content Security Policy for this request.
35
+ #
36
+ # `:default` keeps every structural protection (no framing, no plugins, no
37
+ # `<base>` hijacking, same-origin form submission) while still trusting
38
+ # same-origin scripts, styles, and fonts. That matters because Rails only
39
+ # stamps the per-request nonce onto `stylesheet_link_tag` / `javascript_include_tag`
40
+ # when the host sets `config.content_security_policy_nonce_auto`, which is off
41
+ # by default and which this engine cannot turn on for the host. Without a
42
+ # source expression the host layout's own asset tags are blocked and the
43
+ # dashboard renders unstyled and inert.
44
+ #
45
+ # `:strict` restores the nonce-only policy for applications whose layout
46
+ # serves nothing un-nonced on engine pages. `false`/`nil` declares nothing.
47
+ def apply_api_keys_content_security_policy
48
+ mode = ApiKeys.configuration.dashboard_content_security_policy
49
+ return unless mode # false/nil: leave the host application's policy alone
50
+ return unless request.respond_to?(:content_security_policy=)
51
+
52
+ prepare_api_keys_content_security_policy_nonce
53
+
54
+ policy = request.content_security_policy&.clone || ActionDispatch::ContentSecurityPolicy.new
55
+ policy.base_uri :none
56
+ policy.object_src :none
57
+ policy.frame_ancestors :none
58
+ policy.frame_src :none
59
+ policy.form_action :self
60
+ policy.connect_src :self
61
+
62
+ if mode == :strict
63
+ # Rails appends the per-request nonce to script-src/style-src. `none`
64
+ # leaves the nonce as the only effective source instead of trusting
65
+ # every same-origin script or stylesheet.
66
+ policy.default_src :none
67
+ policy.script_src :none
68
+ policy.style_src :none
69
+ policy.img_src :self, :data
70
+ else
71
+ policy.default_src :self
72
+ policy.script_src :self
73
+ policy.style_src :self
74
+ policy.font_src :self, :data
75
+ policy.img_src :self, :https, :data
76
+ end
77
+
78
+ request.content_security_policy = policy
79
+ request.content_security_policy_report_only = false if request.respond_to?(:content_security_policy_report_only=)
80
+ end
81
+
82
+ def prepare_api_keys_content_security_policy_nonce
83
+ return unless request.respond_to?(:content_security_policy_nonce_generator=)
84
+
85
+ request.content_security_policy_nonce_generator ||= ->(_request) { SecureRandom.base64(16) }
86
+ directives = Array(request.content_security_policy_nonce_directives)
87
+ request.content_security_policy_nonce_directives = directives | %w[script-src style-src]
88
+ request.content_security_policy_nonce
89
+ end
90
+
22
91
  # Authenticates the owner accessing the engine.
23
92
  # Uses the configured authentication method from ApiKeys.configuration
24
93
  def authenticate_api_keys_owner!
25
94
  auth_method = ApiKeys.configuration.authenticate_owner_method
26
95
 
27
- # Try to call the configured authentication method
28
- if auth_method && respond_to?(auth_method, true)
29
- send(auth_method)
30
- elsif auth_method && defined?(auth_method)
31
- send(auth_method)
32
- else
33
- # Fallback: check if owner is present
34
- unless current_api_keys_owner
35
- redirect_to main_app.root_path, alert: "You need to sign in before continuing." rescue render plain: "Unauthorized", status: :unauthorized
96
+ if auth_method.present?
97
+ unless respond_to?(auth_method, true)
98
+ log_error "[ApiKeys Security] Configured owner authentication method is unavailable."
99
+ return render_api_keys_unauthorized
36
100
  end
101
+
102
+ send(auth_method)
103
+ return if performed?
37
104
  end
105
+
106
+ render_api_keys_unauthorized unless current_api_keys_owner
38
107
  end
39
108
 
40
109
  # Helper method to access the current owner from the host application.
@@ -52,5 +121,18 @@ module ApiKeys
52
121
  # Expose current_api_keys_owner as a helper method for views
53
122
  helper_method :current_api_keys_owner
54
123
 
124
+ def render_api_keys_unauthorized
125
+ render plain: "Unauthorized", status: :unauthorized
126
+ end
127
+
128
+ def set_api_keys_security_headers
129
+ response.headers["Cache-Control"] = "no-store, private"
130
+ response.headers["Pragma"] = "no-cache"
131
+ response.headers["Referrer-Policy"] = "no-referrer"
132
+ response.headers["X-Content-Type-Options"] = "nosniff"
133
+ response.headers["X-Frame-Options"] = "DENY"
134
+ response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
135
+ end
136
+
55
137
  end
56
138
  end
@@ -39,7 +39,7 @@ module ApiKeys
39
39
  # We need to retrieve the plaintext token stored temporarily
40
40
  # after creation. This relies on how we handle creation.
41
41
  # We'll likely store it in the session flash or pass it directly.
42
- @plaintext_token = session.delete(:plaintext_api_key) # Retrieve and delete from session
42
+ @plaintext_token = ApiKeys::TokenSession.retrieve_once(session, api_key: @api_key)
43
43
  unless @plaintext_token
44
44
  # If accessed directly without the token, redirect or show an error
45
45
  redirect_to keys_path, alert: "API key token can only be shown once immediately after creation."
@@ -53,22 +53,21 @@ module ApiKeys
53
53
 
54
54
  # POST /keys
55
55
  def create
56
+ submitted_params = api_key_params
57
+
56
58
  # Use the HasApiKeys helper method to create the key
57
59
  begin
58
60
  # create_api_key! now returns the ApiKey instance
59
61
  @api_key = current_api_keys_owner.create_api_key!(
60
- name: api_key_params[:name],
61
- scopes: api_key_params[:scopes],
62
- expires_at: parse_expiration(api_key_params[:expires_at_preset]),
63
- key_type: api_key_params[:key_type].presence&.to_sym
62
+ name: submitted_params[:name],
63
+ scopes: submitted_params[:scopes],
64
+ expires_at: parse_expiration(submitted_params[:expires_at_preset]),
65
+ key_type: submitted_params[:key_type].presence
64
66
  # Metadata could be added here if needed
65
67
  )
66
68
 
67
- # Get the plaintext token from the instance's attr_reader
68
- plaintext_token = @api_key.token
69
-
70
69
  # Store the plaintext token in session to display on the show page
71
- session[:plaintext_api_key] = plaintext_token
70
+ ApiKeys::TokenSession.store(session, @api_key)
72
71
 
73
72
  redirect_to key_path(@api_key)
74
73
  rescue ActiveRecord::RecordInvalid => e
@@ -76,11 +75,18 @@ module ApiKeys
76
75
  @api_key = e.record # Get the invalid ApiKey instance
77
76
  flash.now[:alert] = "Failed to create API key: #{e.record.errors.full_messages.join(', ')}"
78
77
  render :new, status: :unprocessable_entity
79
- rescue => e # Catch other potential errors
80
- flash.now[:alert] = "An unexpected error occurred: #{e.message}"
81
- @api_key = current_api_keys_owner.api_keys.build(api_key_params) # Rebuild form
78
+ rescue ArgumentError
79
+ flash.now[:alert] = "Failed to create API key: invalid options."
80
+ @api_key = rebuild_api_key_for_form(submitted_params)
81
+ render :new, status: :unprocessable_entity
82
+ rescue StandardError => error
83
+ log_error "[ApiKeys Dashboard] Unexpected key creation failure (#{error.class}); request ID: #{request.uuid}"
84
+ flash.now[:alert] = "An unexpected error occurred while creating the API key."
85
+ @api_key = rebuild_api_key_for_form(submitted_params)
82
86
  render :new, status: :unprocessable_entity
83
87
  end
88
+ rescue ActionController::ParameterMissing
89
+ head :bad_request
84
90
  end
85
91
 
86
92
  # GET /keys/:id/edit
@@ -96,6 +102,8 @@ module ApiKeys
96
102
  flash.now[:alert] = "Failed to update API key: #{@api_key.errors.full_messages.join(', ')}"
97
103
  render :edit, status: :unprocessable_entity
98
104
  end
105
+ rescue ActionController::ParameterMissing
106
+ head :bad_request
99
107
  end
100
108
 
101
109
  # POST /keys/:id/revoke
@@ -104,9 +112,9 @@ module ApiKeys
104
112
  redirect_to keys_path, notice: "API key revoked successfully."
105
113
  rescue ApiKeys::Errors::KeyNotRevocableError
106
114
  redirect_to keys_path, alert: "This API key cannot be revoked."
107
- rescue => e
108
- # This shouldn't typically fail unless there's a callback issue
109
- redirect_to keys_path, alert: "Failed to revoke API key: #{e.message}"
115
+ rescue StandardError => error
116
+ log_error "[ApiKeys Dashboard] Unexpected key revocation failure (#{error.class}); key ID: #{@api_key&.id}; request ID: #{request.uuid}"
117
+ redirect_to keys_path, alert: "Failed to revoke API key."
110
118
  end
111
119
 
112
120
  private
@@ -122,14 +130,20 @@ module ApiKeys
122
130
  # Added :expires_at_preset for the dropdown selector.
123
131
  # Added :key_type for the key types feature.
124
132
  def api_key_params
125
- permitted_params = params.require(:api_key).permit(:name, :expires_at_preset, :key_type, scopes: [])
133
+ submitted = params.require(:api_key)
134
+ raise ActionController::ParameterMissing, :api_key unless submitted.respond_to?(:permit)
135
+
136
+ permitted_params = submitted.permit(:name, :expires_at_preset, :key_type, scopes: [])
126
137
  permitted_params[:scopes]&.reject!(&:blank?) # Filter out blank strings
127
138
  permitted_params
128
139
  end
129
140
 
130
141
  # Only allow updating name and scopes.
131
142
  def api_key_update_params
132
- permitted_params = params.require(:api_key).permit(:name, scopes: [])
143
+ submitted = params.require(:api_key)
144
+ raise ActionController::ParameterMissing, :api_key unless submitted.respond_to?(:permit)
145
+
146
+ permitted_params = submitted.permit(:name, scopes: [])
133
147
  permitted_params[:scopes]&.reject!(&:blank?) # Filter out blank strings
134
148
  permitted_params
135
149
  end
@@ -143,10 +157,18 @@ module ApiKeys
143
157
  when "90_days" then 90.days.from_now
144
158
  when "365_days" then 365.days.from_now
145
159
  when "no_expiration" then nil
146
- else nil # Default to no expiration if invalid preset
160
+ when nil, "" then nil
161
+ else raise ArgumentError, "Invalid expiration preset"
147
162
  end
148
163
  end
149
164
 
165
+ def rebuild_api_key_for_form(submitted_params)
166
+ current_api_keys_owner.api_keys.build(
167
+ name: submitted_params[:name],
168
+ scopes: submitted_params[:scopes]
169
+ )
170
+ end
171
+
150
172
  # Check if key types feature is enabled
151
173
  def key_types_feature_enabled?
152
174
  ApiKeys.configuration.key_types.present? && ApiKeys.configuration.key_types.any?
@@ -3,7 +3,7 @@
3
3
 
4
4
  <% message ||= "You don't have any API keys yet!" %>
5
5
 
6
- <div class="api-keys-empty-state" style="text-align: center; padding: 2em;">
6
+ <div class="api-keys-empty-state">
7
7
  <h4><%= message %></h4>
8
8
  <p>Create your first API key to get started.</p>
9
9
  </div>
@@ -1,7 +1,7 @@
1
1
  <%# Shared form for creating and editing API Keys %>
2
2
  <%= form_with(model: [:keys, api_key], url: (api_key.persisted? ? key_path(api_key) : keys_path), local: true) do |form| %>
3
3
  <% if api_key.errors.any? %>
4
- <div style="color: red;">
4
+ <div class="api-keys-form-errors">
5
5
  <strong><%= pluralize(api_key.errors.count, "error") %> prohibited this API key from being saved:</strong>
6
6
  <ul>
7
7
  <% api_key.errors.full_messages.each do |message| %>
@@ -30,7 +30,7 @@
30
30
  %>
31
31
  <% ApiKeys.configuration.key_types.each do |type, config| %>
32
32
  <% if config[:permissions] != :all %>
33
- <small style="display: block; color: #666;">
33
+ <small class="api-keys-form-help">
34
34
  <strong><%= type.to_s.humanize %>:</strong>
35
35
  Limited to <%= config[:permissions]&.join(", ") || "no" %> permissions
36
36
  <% unless config.fetch(:revocable, true) %>
@@ -132,4 +132,4 @@
132
132
  <%= link_to "Cancel", keys_path %>
133
133
  <% end %>
134
134
  </div>
135
- <% end %>
135
+ <% end %>
@@ -2,16 +2,16 @@
2
2
  <%# Locals: key (required) - The ApiKey record %>
3
3
 
4
4
  <% if key.active? %>
5
- <%= link_to api_keys.edit_key_path(key), title: "Edit Key", class: "api-keys-action-edit" do %>
5
+ <%= link_to edit_key_path(key), title: "Edit Key", class: "api-keys-action-edit" do %>
6
6
  <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M16.793 2.793a3.121 3.121 0 1 1 4.414 4.414l-8.5 8.5A1 1 0 0 1 12 16H9a1 1 0 0 1-1-1v-3a1 1 0 0 1 .293-.707l8.5-8.5Zm3 1.414a1.121 1.121 0 0 0-1.586 0L10 12.414V14h1.586l8.207-8.207a1.121 1.121 0 0 0 0-1.586ZM6 5a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-4a1 1 0 1 1 2 0v4a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h4a1 1 0 1 1 0 2H6Z" clip-rule="evenodd"></path></svg>
7
7
  <% end %>
8
8
 
9
9
  <% if key.revocable? %>
10
- <%= button_to api_keys.revoke_key_path(key), title: "Revoke Key", class: "api-keys-action-revoke", data: { turbo_method: :post, turbo_confirm: "Are you sure you want to revoke this key? It will stop working immediately." } do %>
10
+ <%= button_to revoke_key_path(key), title: "Revoke Key", class: "api-keys-action-revoke", data: { turbo_method: :post, turbo_confirm: "Are you sure you want to revoke this key? It will stop working immediately." } do %>
11
11
  <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M10.556 4a1 1 0 0 0-.97.751l-.292 1.14h5.421l-.293-1.14A1 1 0 0 0 13.453 4h-2.897Zm6.224 1.892-.421-1.639A3 3 0 0 0 13.453 2h-2.897A3 3 0 0 0 7.65 4.253l-.421 1.639H4a1 1 0 1 0 0 2h.1l1.215 11.425A3 3 0 0 0 8.3 22h7.4a3 3 0 0 0 2.984-2.683l1.214-11.425H20a1 1 0 1 0 0-2h-3.22Zm1.108 2H6.112l1.192 11.214A1 1 0 0 0 8.3 20h7.4a1 1 0 0 0 .995-.894l1.192-11.214ZM10 10a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Z" clip-rule="evenodd"></path></svg>
12
12
  <% end %>
13
13
  <% else %>
14
- <span title="This key cannot be revoked" class="api-keys-action-disabled" style="color: var(--api-keys-muted-color); cursor: help;">
14
+ <span title="This key cannot be revoked" class="api-keys-action-disabled">
15
15
  <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" clip-rule="evenodd"></path></svg>
16
16
  </span>
17
17
  <% end %>
@@ -4,14 +4,14 @@
4
4
  <% if key.key_type.present? %>
5
5
  <% type_config = key.key_type_config %>
6
6
  <% is_publishable = type_config&.dig(:revocable) == false %>
7
- <span class="api-keys-badge api-keys-badge-type <%= is_publishable ? 'api-keys-badge-publishable' : 'api-keys-badge-secret' %>" style="display: inline-block; padding: 2px 6px; font-size: 0.75em; border-radius: 3px; background-color: var(<%= is_publishable ? '--api-keys-badge-publishable-bg' : '--api-keys-badge-secret-bg' %>); color: var(<%= is_publishable ? '--api-keys-badge-publishable-color' : '--api-keys-badge-secret-color' %>); margin-left: 4px;">
7
+ <span class="api-keys-badge api-keys-badge-type <%= is_publishable ? 'api-keys-badge-publishable' : 'api-keys-badge-secret' %>">
8
8
  <%= key.key_type.humanize %>
9
9
  </span>
10
10
  <% end %>
11
11
 
12
12
  <% if key.environment.present? %>
13
13
  <% is_live = key.environment == 'live' %>
14
- <span class="api-keys-badge api-keys-badge-env <%= is_live ? 'api-keys-badge-live' : 'api-keys-badge-test' %>" style="display: inline-block; padding: 2px 6px; font-size: 0.75em; border-radius: 3px; background-color: var(<%= is_live ? '--api-keys-badge-live-bg' : '--api-keys-badge-test-bg' %>); color: var(<%= is_live ? '--api-keys-badge-live-color' : '--api-keys-badge-test-color' %>); margin-left: 4px;">
14
+ <span class="api-keys-badge api-keys-badge-env <%= is_live ? 'api-keys-badge-live' : 'api-keys-badge-test' %>">
15
15
  <%= key.environment.upcase %>
16
16
  </span>
17
17
  <% end %>
@@ -19,7 +19,7 @@
19
19
  <td class="api-keys-cell-expires">
20
20
  <% if key.expires_at? %>
21
21
  <% if key.expired? %>
22
- <strong style="color: var(--api-keys-status-expired-color);" title="<%= key.expires_at.strftime('%Y-%m-%d %H:%M:%S %Z') %>">
22
+ <strong class="api-keys-expired-text" title="<%= key.expires_at.strftime('%Y-%m-%d %H:%M:%S %Z') %>">
23
23
  Expired <%= time_ago_in_words(key.expires_at) %> ago
24
24
  </strong>
25
25
  <% else %>
@@ -2,9 +2,9 @@
2
2
  <%# Locals: key (required) - The ApiKey record %>
3
3
 
4
4
  <% if key.active? %>
5
- <span class="api-keys-status api-keys-status-active" style="color: var(--api-keys-status-active-color);"></span>
5
+ <span class="api-keys-status api-keys-status-active"></span>
6
6
  <% elsif key.revoked? %>
7
- <span class="api-keys-status api-keys-status-revoked" style="color: var(--api-keys-status-revoked-color);">[Revoked]</span>
7
+ <span class="api-keys-status api-keys-status-revoked">[Revoked]</span>
8
8
  <% elsif key.expired? %>
9
- <span class="api-keys-status api-keys-status-expired" style="color: var(--api-keys-status-expired-color);">[Expired]</span>
9
+ <span class="api-keys-status api-keys-status-expired">[Expired]</span>
10
10
  <% end %>
@@ -28,9 +28,6 @@
28
28
 
29
29
  <%# Render inactive keys below, visually distinct %>
30
30
  <% if inactive_keys.any? %>
31
- <%# Optional: Add a visual separator if desired %>
32
- <%# <tr><td colspan="7" style="border-top: 2px solid #ccc; text-align: center; padding-top: 1em; color: #555;">Inactive Keys</td></tr> %>
33
-
34
31
  <% inactive_keys.each do |key| %>
35
32
  <%= render partial: 'api_keys/keys/key_row', locals: { key: key, inactive: true } %>
36
33
  <% end %>
@@ -44,4 +41,4 @@
44
41
 
45
42
  </section>
46
43
 
47
- <%# The separate inactive keys section is now removed as they are integrated above. %>
44
+ <%# The separate inactive keys section is now removed as they are integrated above. %>
@@ -10,22 +10,22 @@
10
10
  <% end %>
11
11
 
12
12
  <p>
13
- <%= link_to api_keys.security_best_practices_path, class: "text-primary api-keys-align-center" do %>
13
+ <%= link_to security_best_practices_path, class: "text-primary api-keys-align-center" do %>
14
14
  Learn more about API key best practices&nbsp;
15
15
  <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M15 5a1 1 0 1 1 0-2h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V6.414l-5.293 5.293a1 1 0 0 1-1.414-1.414L17.586 5H15ZM4 7a3 3 0 0 1 3-3h3a1 1 0 1 1 0 2H7a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3a1 1 0 1 1 2 0v3a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7Z" clip-rule="evenodd"></path></svg>
16
16
  <% end %>
17
17
  </p>
18
18
 
19
19
 
20
- <div style="padding: 1em; margin: 1em 0; border-radius: 4px;">
20
+ <div class="api-keys-token-panel">
21
21
  <div class="card bd-primary">
22
22
  <div class="row">
23
23
  <div class="col-7 is-vertical-align is-center">
24
- <pre id="api-key-token" style="word-wrap: break-word;"><%= plaintext_token %></pre>
24
+ <pre id="api-key-token" class="api-keys-token-value"><%= plaintext_token %></pre>
25
25
  </div>
26
26
 
27
27
  <div class="col is-vertical-align is-center">
28
- <button id="copy-api-key-button" onclick="copyTokenToClipboard()" class="button primary api-keys-align-center">
28
+ <button id="copy-api-key-button" type="button" class="button primary api-keys-align-center btn-copy-new-token">
29
29
  <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M7 5a3 3 0 0 1 3-3h9a3 3 0 0 1 3 3v9a3 3 0 0 1-3 3h-2v2a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3v-9a3 3 0 0 1 3-3h2V5Zm2 2h5a3 3 0 0 1 3 3v5h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-9a1 1 0 0 0-1 1v2ZM5 9a1 1 0 0 0-1 1v9a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1v-9a1 1 0 0 0-1-1H5Z" clip-rule="evenodd"></path></svg>
30
30
  <span class="api-keys-button-text">Copy</span>
31
31
  </button>
@@ -36,7 +36,7 @@
36
36
 
37
37
 
38
38
  <% if api_key.scopes.present? %>
39
- <div style="margin-top: 2em;">
39
+ <div class="api-keys-token-scopes">
40
40
  <p><strong>Permissions</strong></p>
41
41
 
42
42
  <% if api_key.scopes.present? %>
@@ -49,44 +49,3 @@
49
49
  </div>
50
50
  <% end %>
51
51
  </div>
52
-
53
- <%# Simple JavaScript for copy-to-clipboard functionality %>
54
- <%# Ensure this script is loaded only once if rendering multiple components %>
55
- <script>
56
- function copyTokenToClipboard() {
57
- const tokenElement = document.getElementById('api-key-token');
58
- const copyButton = document.getElementById('copy-api-key-button');
59
- const buttonTextElement = copyButton.querySelector('.api-keys-button-text'); // Target the span containing the text
60
- const originalButtonText = buttonTextElement.innerHTML; // Store original HTML
61
-
62
- if (navigator.clipboard && tokenElement && copyButton && buttonTextElement) {
63
- navigator.clipboard.writeText(tokenElement.textContent || '').then(() => {
64
- buttonTextElement.innerHTML = 'Copied!'; // Update text
65
- copyButton.classList.add('success'); // Optional: Add class for styling
66
- setTimeout(() => {
67
- buttonTextElement.innerHTML = originalButtonText; // Revert text
68
- copyButton.classList.remove('success'); // Optional: Remove class
69
- }, 2000);
70
- }).catch(err => {
71
- buttonTextElement.innerHTML = 'Failed'; // Update text on error
72
- copyButton.classList.add('error'); // Optional: Add class for styling
73
- console.error('Failed to copy text: ', err);
74
- setTimeout(() => {
75
- buttonTextElement.innerHTML = originalButtonText; // Revert text
76
- copyButton.classList.remove('error'); // Optional: Remove class
77
- }, 2000);
78
- });
79
- } else {
80
- // Fallback or indicate unavailability more clearly if needed
81
- buttonTextElement.innerHTML = 'Cannot Copy';
82
- copyButton.classList.add('error'); // Optional: Add class for styling
83
- console.warn('Clipboard API not available or element missing.');
84
- setTimeout(() => {
85
- buttonTextElement.innerHTML = originalButtonText; // Revert text
86
- copyButton.classList.remove('error'); // Optional: Remove class
87
- }, 2000);
88
- }
89
- }
90
- // Automatically try to copy when the partial is rendered, if desired?
91
- // document.addEventListener('DOMContentLoaded', copyTokenToClipboard); // Example
92
- </script>
@@ -3,9 +3,9 @@
3
3
 
4
4
  <% if key.public_key_type? && key.viewable_token.present? %>
5
5
  <span class="token-masked"><code><%= key.masked_token %></code></span>
6
- <span class="token-full" style="display: none;"><code style="word-break: break-all;"><%= key.viewable_token %></code></span>
7
- <button type="button" class="btn-show-token" style="margin-left: 8px; font-size: 0.75em; padding: 2px 6px; cursor: pointer;" title="Show full token">Show</button>
8
- <button type="button" class="btn-copy-token" style="display: none; margin-left: 4px; font-size: 0.75em; padding: 2px 6px; cursor: pointer;" title="Copy to clipboard" data-token="<%= key.viewable_token %>">Copy</button>
6
+ <span class="token-full" hidden><code class="api-keys-token-break"><%= key.viewable_token %></code></span>
7
+ <button type="button" class="btn-show-token" title="Show full token">Show</button>
8
+ <button type="button" class="btn-copy-token" title="Copy to clipboard" data-token="<%= key.viewable_token %>" hidden>Copy</button>
9
9
  <% else %>
10
10
  <code><%= key.masked_token %></code>
11
11
  <% end %>
@@ -25,7 +25,7 @@
25
25
  <% else %>
26
26
  Do not share your API key with others or expose it in the browser or other client-side code.
27
27
  <% end %>
28
- <%= link_to api_keys.security_best_practices_path, class: "text-primary api-keys-align-center" do %>
28
+ <%= link_to security_best_practices_path, class: "text-primary api-keys-align-center" do %>
29
29
  Learn more&nbsp;
30
30
  <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M15 5a1 1 0 1 1 0-2h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V6.414l-5.293 5.293a1 1 0 0 1-1.414-1.414L17.586 5H15ZM4 7a3 3 0 0 1 3-3h3a1 1 0 1 1 0 2H7a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3a1 1 0 1 1 2 0v3a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7Z" clip-rule="evenodd"></path></svg>
31
31
  <% end %>
@@ -46,7 +46,7 @@
46
46
  <% end %>
47
47
 
48
48
  <% else %>
49
- <p>Do not share your API key with others or expose it in the browser or other client-side code. <%= link_to api_keys.security_best_practices_path, class: "text-primary api-keys-align-center" do %>
49
+ <p>Do not share your API key with others or expose it in the browser or other client-side code. <%= link_to security_best_practices_path, class: "text-primary api-keys-align-center" do %>
50
50
  Learn more&nbsp;
51
51
  <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M15 5a1 1 0 1 1 0-2h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V6.414l-5.293 5.293a1 1 0 0 1-1.414-1.414L17.586 5H15ZM4 7a3 3 0 0 1 3-3h3a1 1 0 1 1 0 2H7a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3a1 1 0 1 1 2 0v3a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7Z" clip-rule="evenodd"></path></svg>
52
52
  <% end %>
@@ -4,9 +4,9 @@
4
4
  <%= render partial: 'show_token', locals: { api_key: @api_key, plaintext_token: @plaintext_token } %>
5
5
 
6
6
  <%# Add navigation links %>
7
- <p style="margin-top: 2em;">
7
+ <p class="api-keys-show-footer">
8
8
  <%= link_to ApiKeys.configuration.return_text, ApiKeys.configuration.return_url, class: "button outline" %>
9
9
  <%= link_to "All API Keys", keys_path, class: "button" %>
10
10
  </p>
11
11
 
12
- </div>
12
+ </div>
@@ -10,7 +10,7 @@
10
10
  <h2>Understanding Key Types</h2>
11
11
 
12
12
  <h3>Secret Keys</h3>
13
- <p>Secret keys provide full access to your account and should be treated like passwords.</p>
13
+ <p>Secret keys may grant sensitive or broad access, depending on their configured permissions, and must be treated like passwords.</p>
14
14
  <ul>
15
15
  <li><strong>Never expose</strong> in client-side code (browsers, mobile apps, desktop apps)</li>
16
16
  <li><strong>Never commit</strong> to version control (Git, etc.)</li>
@@ -19,12 +19,12 @@
19
19
  </ul>
20
20
 
21
21
  <h3>Publishable Keys</h3>
22
- <p>Publishable keys are designed for client-side use with limited, safe permissions.</p>
22
+ <p>Publishable keys are intentionally exposed identifiers. They are suitable for client-side use <strong>only when every configured permission is safe for an untrusted public client</strong>.</p>
23
23
  <ul>
24
- <li><strong>Safe to embed</strong> in browser JavaScript, mobile apps, and public code</li>
25
- <li><strong>Limited access</strong> &mdash; cannot perform sensitive operations</li>
24
+ <li><strong>Assume anyone can copy and abuse them</strong> from browser JavaScript, mobile apps, or public code</li>
25
+ <li><strong>Grant only public-safe operations</strong> and enforce authorization server-side for every permission</li>
26
26
  <li><strong>Always visible</strong> &mdash; you can view the full key anytime in your dashboard</li>
27
- <li><strong>Cannot be revoked</strong> &mdash; designed to be long-lived identifiers</li>
27
+ <li><strong>Cannot be revoked individually</strong> &mdash; maintain an application-level disable/replacement procedure</li>
28
28
  </ul>
29
29
  </section>
30
30
  <% end %>
@@ -76,7 +76,7 @@ Rails.application.credentials.your_service_api_key</code></pre>
76
76
  <h3>Monitor Usage</h3>
77
77
  <p>Regularly check for unexpected activity spikes or requests from unusual locations, which could indicate a compromised key.</p>
78
78
 
79
- <h3>Rotate Keys Periodically</h3>
79
+ <h3>Rotate Secret Keys Periodically</h3>
80
80
  <p>Generate new keys and revoke old ones on a regular schedule (e.g., every 90 days). This limits exposure if a key is leaked undetected.</p>
81
81
  <p><em>Tip:</em> Create a new key first, update your application, verify it works, then revoke the old key.</p>
82
82
 
@@ -91,6 +91,6 @@ Rails.application.credentials.your_service_api_key</code></pre>
91
91
 
92
92
  <hr>
93
93
 
94
- <p><%= link_to "Back to API Keys", api_keys.keys_path, class: "text-primary" %></p>
94
+ <p><%= link_to "Back to API Keys", keys_path, class: "text-primary" %></p>
95
95
 
96
96
  </article>