api_keys 0.3.0 → 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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/README.md +101 -39
  4. data/SECURITY.md +33 -0
  5. data/app/controllers/api_keys/application_controller.rb +58 -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 +374 -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 +32 -0
  38. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +2 -3
  39. data/lib/generators/api_keys/templates/initializer.rb +36 -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
@@ -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>
@@ -4,14 +4,12 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>🔑 API Keys</title>
7
- <%# Note: No CSS included here to keep it CSS-framework-agnostic %>
8
- <link rel="stylesheet" href="https://unpkg.com/chota@latest">
9
- <%# Host application is expected to provide styling %>
7
+ <%# Self-contained styles: do not load mutable third-party assets on secret-bearing pages. %>
10
8
  <%= csrf_meta_tags %>
11
9
  <%= csp_meta_tag %>
12
10
  </head>
13
11
  <body>
14
- <style>
12
+ <style nonce="<%= content_security_policy_nonce %>">
15
13
  /*
16
14
  * API Keys CSS Variables
17
15
  * Override these in your host application to customize the dashboard appearance.
@@ -56,6 +54,92 @@
56
54
 
57
55
  body {
58
56
  font-family: var(--api-keys-font-family);
57
+ margin: 0;
58
+ color: var(--font-color, #222);
59
+ background: var(--bg-color, #fff);
60
+ line-height: 1.5;
61
+ }
62
+
63
+ .container {
64
+ width: min(94%, 1080px);
65
+ margin-inline: auto;
66
+ }
67
+
68
+ .nav, .row {
69
+ display: flex;
70
+ align-items: center;
71
+ gap: 1rem;
72
+ }
73
+
74
+ .nav {
75
+ justify-content: space-between;
76
+ min-height: 4rem;
77
+ }
78
+
79
+ .nav-left, .nav-right {
80
+ display: flex;
81
+ gap: 1rem;
82
+ }
83
+
84
+ .col { flex: 1; }
85
+ .col-5 { width: min(100%, 42rem); }
86
+ .col-7 { flex: 0 0 58%; }
87
+ .col-8 { width: min(100%, 54rem); }
88
+ .is-right { justify-content: flex-end; }
89
+ .is-center { justify-content: center; }
90
+ .text-center { text-align: center; }
91
+
92
+ input, select, button, .button {
93
+ box-sizing: border-box;
94
+ font: inherit;
95
+ }
96
+
97
+ input[type="text"], select {
98
+ width: 100%;
99
+ padding: 0.65rem;
100
+ border: 1px solid var(--color-grey, #bbb);
101
+ border-radius: 4px;
102
+ background: var(--bg-color, #fff);
103
+ color: inherit;
104
+ }
105
+
106
+ button, .button {
107
+ display: inline-block;
108
+ padding: 0.55rem 0.85rem;
109
+ border: 1px solid currentColor;
110
+ border-radius: 4px;
111
+ cursor: pointer;
112
+ text-decoration: none;
113
+ }
114
+
115
+ .primary {
116
+ color: #fff;
117
+ background: var(--api-keys-primary-color);
118
+ }
119
+
120
+ .card {
121
+ padding: 1rem;
122
+ border: 1px solid var(--color-grey, #ccc);
123
+ border-radius: var(--api-keys-border-radius);
124
+ }
125
+
126
+ table {
127
+ width: 100%;
128
+ border-collapse: collapse;
129
+ }
130
+
131
+ th, td {
132
+ padding: 0.65rem;
133
+ border-bottom: 1px solid var(--color-grey, #ddd);
134
+ text-align: left;
135
+ }
136
+
137
+ .api-keys-table-wrapper { overflow-x: auto; }
138
+ .tag, .api-keys-badge {
139
+ display: inline-block;
140
+ padding: 0.15rem 0.4rem;
141
+ border-radius: 999px;
142
+ background: var(--bg-secondary-color, #eee);
59
143
  }
60
144
 
61
145
  @media (prefers-color-scheme: dark) {
@@ -107,6 +191,50 @@
107
191
  color: var(--api-keys-danger-color);
108
192
  }
109
193
 
194
+ [hidden] { display: none !important; }
195
+ .api-keys-form-errors { color: var(--api-keys-danger-color); }
196
+ .api-keys-form-help { display: block; color: var(--color-darkGrey, #666); }
197
+ .api-keys-empty-state { text-align: center; padding: 2em; }
198
+ .api-keys-expired-text { color: var(--api-keys-status-expired-color); }
199
+ .api-keys-token-panel { padding: 1em; margin: 1em 0; border-radius: 4px; }
200
+ .api-keys-token-value { overflow-wrap: anywhere; }
201
+ .api-keys-token-scopes, .api-keys-show-footer { margin-top: 2em; }
202
+ .api-keys-token-break { overflow-wrap: anywhere; }
203
+ .btn-show-token, .btn-copy-token {
204
+ margin-left: 0.5rem;
205
+ padding: 0.15rem 0.4rem;
206
+ font-size: 0.75em;
207
+ }
208
+ .api-keys-action-disabled {
209
+ color: var(--api-keys-muted-color);
210
+ cursor: help;
211
+ }
212
+ .api-keys-status-active { color: var(--api-keys-status-active-color); }
213
+ .api-keys-status-revoked { color: var(--api-keys-status-revoked-color); }
214
+ .api-keys-status-expired { color: var(--api-keys-status-expired-color); }
215
+ .api-keys-badge-type, .api-keys-badge-env {
216
+ margin-left: 0.25rem;
217
+ padding: 0.15rem 0.4rem;
218
+ border-radius: 3px;
219
+ font-size: 0.75em;
220
+ }
221
+ .api-keys-badge-publishable {
222
+ color: var(--api-keys-badge-publishable-color);
223
+ background-color: var(--api-keys-badge-publishable-bg);
224
+ }
225
+ .api-keys-badge-secret {
226
+ color: var(--api-keys-badge-secret-color);
227
+ background-color: var(--api-keys-badge-secret-bg);
228
+ }
229
+ .api-keys-badge-live {
230
+ color: var(--api-keys-badge-live-color);
231
+ background-color: var(--api-keys-badge-live-bg);
232
+ }
233
+ .api-keys-badge-test {
234
+ color: var(--api-keys-badge-test-color);
235
+ background-color: var(--api-keys-badge-test-bg);
236
+ }
237
+
110
238
  .api-keys-button-text {
111
239
  padding-left: 0.2em;
112
240
  }
@@ -114,7 +242,7 @@
114
242
  #api-keys-flash-container {
115
243
  margin-top: 1em;
116
244
  }
117
-
245
+
118
246
  .api-keys-flash-message {
119
247
  background-color: rgba(255, 255, 255, 0.15);
120
248
  padding: 0.8em 0;
@@ -168,7 +296,7 @@
168
296
  <%# Use configured return URL and text %>
169
297
  <%= link_to ApiKeys.configuration.return_text, ApiKeys.configuration.return_url %>
170
298
  <%# Link to keys index using engine's path helper %>
171
- <%= link_to "My API Keys", api_keys.keys_path, class: "active" %>
299
+ <%= link_to "My API Keys", keys_path, class: "active" %>
172
300
  </div>
173
301
  <div class="nav-center">
174
302
  </div>
@@ -193,16 +321,16 @@
193
321
  <%# Footer content %>
194
322
  </footer>
195
323
 
196
- <script>
324
+ <script nonce="<%= content_security_policy_nonce %>">
197
325
  // Minimal JS for API Keys dashboard - event delegation for show/copy tokens
198
326
  document.addEventListener('click', function(e) {
199
327
  // Show token (one-way: masked -> full, no hiding back)
200
328
  if (e.target.matches('.btn-show-token')) {
201
329
  var cell = e.target.closest('td');
202
- cell.querySelector('.token-masked').style.display = 'none';
203
- cell.querySelector('.token-full').style.display = 'inline';
204
- e.target.style.display = 'none';
205
- cell.querySelector('.btn-copy-token').style.display = 'inline';
330
+ cell.querySelector('.token-masked').hidden = true;
331
+ cell.querySelector('.token-full').hidden = false;
332
+ e.target.hidden = true;
333
+ cell.querySelector('.btn-copy-token').hidden = false;
206
334
  }
207
335
 
208
336
  // Copy token to clipboard
@@ -212,10 +340,29 @@
212
340
  var originalText = e.target.textContent;
213
341
  e.target.textContent = 'Copied!';
214
342
  setTimeout(function() { e.target.textContent = originalText; }, 2000);
343
+ }).catch(function() {
344
+ e.target.textContent = 'Copy failed';
345
+ setTimeout(function() { e.target.textContent = 'Copy'; }, 2000);
346
+ });
347
+ }
348
+
349
+ // Copy a newly issued secret without duplicating it into an HTML attribute.
350
+ if (e.target.closest('.btn-copy-new-token')) {
351
+ var button = e.target.closest('.btn-copy-new-token');
352
+ var tokenElement = document.getElementById('api-key-token');
353
+ var buttonText = button.querySelector('.api-keys-button-text');
354
+ if (!navigator.clipboard || !tokenElement || !buttonText) return;
355
+
356
+ navigator.clipboard.writeText(tokenElement.textContent || '').then(function() {
357
+ buttonText.textContent = 'Copied!';
358
+ setTimeout(function() { buttonText.textContent = 'Copy'; }, 2000);
359
+ }).catch(function() {
360
+ buttonText.textContent = 'Copy failed';
361
+ setTimeout(function() { buttonText.textContent = 'Copy'; }, 2000);
215
362
  });
216
363
  }
217
364
  });
218
365
  </script>
219
366
 
220
367
  </body>
221
- </html>
368
+ </html>