api_keys 0.2.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -0
  3. data/README.md +851 -25
  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 +77 -23
  7. data/app/controllers/api_keys/security_controller.rb +8 -0
  8. data/app/views/api_keys/keys/_empty_state.html.erb +9 -0
  9. data/app/views/api_keys/keys/_form.html.erb +33 -4
  10. data/app/views/api_keys/keys/_key_actions.html.erb +20 -0
  11. data/app/views/api_keys/keys/_key_badges.html.erb +17 -0
  12. data/app/views/api_keys/keys/_key_row.html.erb +21 -35
  13. data/app/views/api_keys/keys/_key_status.html.erb +10 -0
  14. data/app/views/api_keys/keys/_keys_table.html.erb +3 -11
  15. data/app/views/api_keys/keys/_publishable_keys.html.erb +40 -0
  16. data/app/views/api_keys/keys/_secret_keys.html.erb +39 -0
  17. data/app/views/api_keys/keys/_show_token.html.erb +10 -47
  18. data/app/views/api_keys/keys/_token_display.html.erb +11 -0
  19. data/app/views/api_keys/keys/index.html.erb +40 -8
  20. data/app/views/api_keys/keys/show.html.erb +2 -2
  21. data/app/views/api_keys/security/best_practices.html.erb +73 -47
  22. data/app/views/layouts/api_keys/application.html.erb +267 -14
  23. data/lib/api_keys/authentication.rb +39 -11
  24. data/lib/api_keys/configuration.rb +444 -17
  25. data/lib/api_keys/engine.rb +5 -20
  26. data/lib/api_keys/errors.rb +73 -0
  27. data/lib/api_keys/form_builder_extensions.rb +168 -0
  28. data/lib/api_keys/helpers/expiration_options.rb +139 -0
  29. data/lib/api_keys/helpers/token_session.rb +203 -0
  30. data/lib/api_keys/helpers/view_helpers.rb +220 -0
  31. data/lib/api_keys/jobs/callbacks_job.rb +10 -17
  32. data/lib/api_keys/jobs/update_stats_job.rb +27 -12
  33. data/lib/api_keys/models/api_key.rb +452 -21
  34. data/lib/api_keys/models/concerns/has_api_keys.rb +269 -26
  35. data/lib/api_keys/services/authenticator.rb +300 -112
  36. data/lib/api_keys/services/digestor.rb +81 -14
  37. data/lib/api_keys/services/token_generator.rb +41 -1
  38. data/lib/api_keys/tenant_resolution.rb +4 -4
  39. data/lib/api_keys/version.rb +1 -1
  40. data/lib/api_keys.rb +12 -0
  41. data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
  42. data/lib/generators/api_keys/add_key_types_generator.rb +68 -0
  43. data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +32 -0
  44. data/lib/generators/api_keys/templates/add_key_types_to_api_keys.rb.erb +18 -0
  45. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +11 -3
  46. data/lib/generators/api_keys/templates/initializer.rb +261 -120
  47. metadata +29 -63
  48. data/Rakefile +0 -32
data/SECURITY.md ADDED
@@ -0,0 +1,33 @@
1
+ # Security Policy
2
+
3
+ `api_keys` handles authentication credentials. Please report suspected vulnerabilities privately and avoid including real API keys, production data, or customer information in any report.
4
+
5
+ ## Supported versions
6
+
7
+ Security fixes are released for the latest published version. The maintained test matrix covers Ruby 3.3, 3.4, and 4.0 with patched Rails 7.2, 8.0, and 8.1 releases. Older Ruby and Rails versions may remain installable for compatibility, but runtimes that no longer receive upstream security fixes are not security-supported.
8
+
9
+ ## Reporting a vulnerability
10
+
11
+ Use GitHub's **Report a vulnerability** button on the [`api_keys` security advisories page](https://github.com/rameerez/api_keys/security/advisories) so the report and any proposed fix remain private. If GitHub's private reporting flow is unavailable, email `rubygems@rameerez.com` with the subject `api_keys security report`.
12
+
13
+ Include:
14
+
15
+ - the affected version and environment;
16
+ - a minimal reproduction or proof of concept;
17
+ - the impact you believe is possible; and
18
+ - any suggested mitigation or patch.
19
+
20
+ Do not open a public issue for an undisclosed vulnerability. We will acknowledge the report, investigate it, and coordinate disclosure and credit with you. If the issue affects downstream applications, we will prioritize a patched release and clear upgrade guidance.
21
+
22
+ ## Operational security
23
+
24
+ Applications remain responsible for:
25
+
26
+ - trusted TLS/proxy configuration, endpoint authorization, and request rate limiting;
27
+ - encrypted, `Secure`, `HttpOnly`, appropriately `SameSite` session cookies for the one-time dashboard token handoff;
28
+ - database, cache, queue, log, backup, and observability access controls;
29
+ - filtering any query-parameter credential name from application and proxy logs if that opt-in transport is enabled;
30
+ - classifying every permission on a `public: true` key type as safe for an untrusted public client and maintaining a disable/replacement procedure for non-revocable identifiers; and
31
+ - prompt dependency/runtime patching and a durable background-job backend where callbacks or usage statistics are required.
32
+
33
+ The gem bounds authentication work, but applications should still rate-limit credential endpoints. Do not treat a key prefix, last four characters, cache entry, or public key as proof of authorization.
@@ -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,61 @@ 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
+ before_action :prepare_api_keys_content_security_policy_nonce
22
+ if respond_to?(:content_security_policy)
23
+ content_security_policy do |policy|
24
+ policy.default_src :none
25
+ policy.base_uri :none
26
+ policy.object_src :none
27
+ policy.frame_ancestors :none
28
+ policy.frame_src :none
29
+ policy.form_action :self
30
+ # Rails appends the per-request nonce to these directives. `none` leaves
31
+ # the nonce as the only effective source instead of trusting every
32
+ # same-origin script or stylesheet.
33
+ policy.script_src :none
34
+ policy.style_src :none
35
+ policy.img_src :self, :data
36
+ policy.connect_src :self
37
+ end
38
+ content_security_policy_report_only false
39
+ end
40
+
16
41
  # Ensure the owner is authenticated for all actions within this engine
17
42
  # This uses the configured authentication method (defaults to authenticate_user!)
18
43
  before_action :authenticate_api_keys_owner!
44
+ after_action :set_api_keys_security_headers
19
45
 
20
46
  private
21
47
 
48
+ def prepare_api_keys_content_security_policy_nonce
49
+ return unless request.respond_to?(:content_security_policy_nonce_generator=)
50
+
51
+ request.content_security_policy_nonce_generator ||= ->(_request) { SecureRandom.base64(16) }
52
+ directives = Array(request.content_security_policy_nonce_directives)
53
+ request.content_security_policy_nonce_directives = directives | %w[script-src style-src]
54
+ request.content_security_policy_nonce
55
+ end
56
+
22
57
  # Authenticates the owner accessing the engine.
23
58
  # Uses the configured authentication method from ApiKeys.configuration
24
59
  def authenticate_api_keys_owner!
25
60
  auth_method = ApiKeys.configuration.authenticate_owner_method
26
61
 
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
62
+ if auth_method.present?
63
+ unless respond_to?(auth_method, true)
64
+ log_error "[ApiKeys Security] Configured owner authentication method is unavailable."
65
+ return render_api_keys_unauthorized
36
66
  end
67
+
68
+ send(auth_method)
69
+ return if performed?
37
70
  end
71
+
72
+ render_api_keys_unauthorized unless current_api_keys_owner
38
73
  end
39
74
 
40
75
  # Helper method to access the current owner from the host application.
@@ -52,5 +87,18 @@ module ApiKeys
52
87
  # Expose current_api_keys_owner as a helper method for views
53
88
  helper_method :current_api_keys_owner
54
89
 
90
+ def render_api_keys_unauthorized
91
+ render plain: "Unauthorized", status: :unauthorized
92
+ end
93
+
94
+ def set_api_keys_security_headers
95
+ response.headers["Cache-Control"] = "no-store, private"
96
+ response.headers["Pragma"] = "no-cache"
97
+ response.headers["Referrer-Policy"] = "no-referrer"
98
+ response.headers["X-Content-Type-Options"] = "nosniff"
99
+ response.headers["X-Frame-Options"] = "DENY"
100
+ response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
101
+ end
102
+
55
103
  end
56
104
  end
@@ -4,13 +4,31 @@ module ApiKeys
4
4
  # Controller for managing API keys belonging to the current owner.
5
5
  class KeysController < ApplicationController
6
6
  before_action :set_api_key, only: [:show, :edit, :update, :revoke]
7
+ helper_method :key_types_feature_enabled?
7
8
 
8
9
  # GET /keys
9
10
  def index
10
- # Fetch only active keys for the main list, maybe sorted by creation date
11
- @api_keys = current_api_keys_owner.api_keys.active.order(created_at: :desc)
11
+ # Start with all keys for the owner
12
+ base_scope = current_api_keys_owner.api_keys
13
+
14
+ # Filter by environment if key_types feature is enabled and cross-environment is disabled
15
+ if key_types_feature_enabled? && !ApiKeys.configuration.dashboard_allow_cross_environment
16
+ current_env = resolve_current_environment
17
+ base_scope = base_scope.where(environment: [current_env.to_s, nil, ""])
18
+ end
19
+
20
+ # Fetch only active keys for the main list, sorted by creation date
21
+ @api_keys = base_scope.active.order(created_at: :desc)
12
22
  # Optionally, fetch inactive ones for a separate section or filter
13
- @inactive_api_keys = current_api_keys_owner.api_keys.inactive.order(created_at: :desc)
23
+ @inactive_api_keys = base_scope.inactive.order(created_at: :desc)
24
+
25
+ # When key_types feature is enabled, separate publishable and secret keys
26
+ if key_types_feature_enabled?
27
+ @publishable_keys = @api_keys.select(&:public_key_type?)
28
+ @secret_keys = @api_keys.reject(&:public_key_type?)
29
+ @inactive_publishable_keys = @inactive_api_keys.select(&:public_key_type?)
30
+ @inactive_secret_keys = @inactive_api_keys.reject(&:public_key_type?)
31
+ end
14
32
  end
15
33
 
16
34
  # GET /keys/:id
@@ -21,7 +39,7 @@ module ApiKeys
21
39
  # We need to retrieve the plaintext token stored temporarily
22
40
  # after creation. This relies on how we handle creation.
23
41
  # We'll likely store it in the session flash or pass it directly.
24
- @plaintext_token = session.delete(:plaintext_api_key) # Retrieve and delete from session
42
+ @plaintext_token = ApiKeys::TokenSession.retrieve_once(session, api_key: @api_key)
25
43
  unless @plaintext_token
26
44
  # If accessed directly without the token, redirect or show an error
27
45
  redirect_to keys_path, alert: "API key token can only be shown once immediately after creation."
@@ -35,21 +53,21 @@ module ApiKeys
35
53
 
36
54
  # POST /keys
37
55
  def create
56
+ submitted_params = api_key_params
57
+
38
58
  # Use the HasApiKeys helper method to create the key
39
59
  begin
40
60
  # create_api_key! now returns the ApiKey instance
41
61
  @api_key = current_api_keys_owner.create_api_key!(
42
- name: api_key_params[:name],
43
- scopes: api_key_params[:scopes],
44
- expires_at: parse_expiration(api_key_params[:expires_at_preset])
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
45
66
  # Metadata could be added here if needed
46
67
  )
47
68
 
48
- # Get the plaintext token from the instance's attr_reader
49
- plaintext_token = @api_key.token
50
-
51
69
  # Store the plaintext token in session to display on the show page
52
- session[:plaintext_api_key] = plaintext_token
70
+ ApiKeys::TokenSession.store(session, @api_key)
53
71
 
54
72
  redirect_to key_path(@api_key)
55
73
  rescue ActiveRecord::RecordInvalid => e
@@ -57,11 +75,18 @@ module ApiKeys
57
75
  @api_key = e.record # Get the invalid ApiKey instance
58
76
  flash.now[:alert] = "Failed to create API key: #{e.record.errors.full_messages.join(', ')}"
59
77
  render :new, status: :unprocessable_entity
60
- rescue => e # Catch other potential errors
61
- flash.now[:alert] = "An unexpected error occurred: #{e.message}"
62
- @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)
63
86
  render :new, status: :unprocessable_entity
64
87
  end
88
+ rescue ActionController::ParameterMissing
89
+ head :bad_request
65
90
  end
66
91
 
67
92
  # GET /keys/:id/edit
@@ -77,16 +102,19 @@ module ApiKeys
77
102
  flash.now[:alert] = "Failed to update API key: #{@api_key.errors.full_messages.join(', ')}"
78
103
  render :edit, status: :unprocessable_entity
79
104
  end
105
+ rescue ActionController::ParameterMissing
106
+ head :bad_request
80
107
  end
81
108
 
82
109
  # POST /keys/:id/revoke
83
110
  def revoke
84
- if @api_key.revoke!
85
- redirect_to keys_path, notice: "API key revoked successfully."
86
- else
87
- # This shouldn't typically fail unless there's a callback issue
88
- redirect_to keys_path, alert: "Failed to revoke API key."
89
- end
111
+ @api_key.revoke!
112
+ redirect_to keys_path, notice: "API key revoked successfully."
113
+ rescue ApiKeys::Errors::KeyNotRevocableError
114
+ redirect_to keys_path, alert: "This API key cannot be revoked."
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."
90
118
  end
91
119
 
92
120
  private
@@ -100,15 +128,22 @@ module ApiKeys
100
128
 
101
129
  # Only allow a list of trusted parameters through for creation.
102
130
  # Added :expires_at_preset for the dropdown selector.
131
+ # Added :key_type for the key types feature.
103
132
  def api_key_params
104
- permitted_params = params.require(:api_key).permit(:name, :expires_at_preset, 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: [])
105
137
  permitted_params[:scopes]&.reject!(&:blank?) # Filter out blank strings
106
138
  permitted_params
107
139
  end
108
140
 
109
141
  # Only allow updating name and scopes.
110
142
  def api_key_update_params
111
- 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: [])
112
147
  permitted_params[:scopes]&.reject!(&:blank?) # Filter out blank strings
113
148
  permitted_params
114
149
  end
@@ -122,8 +157,27 @@ module ApiKeys
122
157
  when "90_days" then 90.days.from_now
123
158
  when "365_days" then 365.days.from_now
124
159
  when "no_expiration" then nil
125
- else nil # Default to no expiration if invalid preset
160
+ when nil, "" then nil
161
+ else raise ArgumentError, "Invalid expiration preset"
126
162
  end
127
163
  end
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
+
172
+ # Check if key types feature is enabled
173
+ def key_types_feature_enabled?
174
+ ApiKeys.configuration.key_types.present? && ApiKeys.configuration.key_types.any?
175
+ end
176
+
177
+ # Get the current environment from configuration
178
+ def resolve_current_environment
179
+ env_config = ApiKeys.configuration.current_environment
180
+ env_config.respond_to?(:call) ? env_config.call : env_config
181
+ end
128
182
  end
129
183
  end
@@ -6,11 +6,19 @@ module ApiKeys
6
6
  # Skip the user authentication requirement for these static pages
7
7
  # as they contain general information.
8
8
  skip_before_action :authenticate_api_keys_owner!, only: [:best_practices]
9
+ helper_method :key_types_feature_enabled?
9
10
 
10
11
  # GET /security/best-practices
11
12
  def best_practices
12
13
  # Renders app/views/api_keys/security/best_practices.html.erb
13
14
  # The view will contain the static content.
14
15
  end
16
+
17
+ private
18
+
19
+ # Check if key types feature is enabled
20
+ def key_types_feature_enabled?
21
+ ApiKeys.configuration.key_types.present? && ApiKeys.configuration.key_types.any?
22
+ end
15
23
  end
16
24
  end
@@ -0,0 +1,9 @@
1
+ <%# Partial for displaying empty state when no keys exist %>
2
+ <%# Locals: message (optional) - Custom message to display %>
3
+
4
+ <% message ||= "You don't have any API keys yet!" %>
5
+
6
+ <div class="api-keys-empty-state">
7
+ <h4><%= message %></h4>
8
+ <p>Create your first API key to get started.</p>
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| %>
@@ -13,6 +13,35 @@
13
13
 
14
14
  <%# Fields only available on NEW %>
15
15
  <% unless api_key.persisted? %>
16
+ <%# Key Type selector (only shown when key_types feature is enabled) %>
17
+ <% if ApiKeys.configuration.key_types.present? && ApiKeys.configuration.key_types.any? %>
18
+ <div>
19
+ <%= form.label :key_type, "Key Type" %>
20
+ <%= form.select :key_type,
21
+ options_for_select(
22
+ ApiKeys.configuration.key_types.map { |type, config|
23
+ label = type.to_s.humanize
24
+ label += " (#{config[:prefix]}_)" if config[:prefix]
25
+ [label, type.to_s]
26
+ },
27
+ api_key.key_type
28
+ ),
29
+ { prompt: "Select key type..." }
30
+ %>
31
+ <% ApiKeys.configuration.key_types.each do |type, config| %>
32
+ <% if config[:permissions] != :all %>
33
+ <small class="api-keys-form-help">
34
+ <strong><%= type.to_s.humanize %>:</strong>
35
+ Limited to <%= config[:permissions]&.join(", ") || "no" %> permissions
36
+ <% unless config.fetch(:revocable, true) %>
37
+ <em>(cannot be revoked)</em>
38
+ <% end %>
39
+ </small>
40
+ <% end %>
41
+ <% end %>
42
+ </div>
43
+ <% end %>
44
+
16
45
  <div>
17
46
  <%= form.label :name, "Name (optional)" %>
18
47
  <%= form.text_field :name, placeholder: "e.g., myproject-production-key" %>
@@ -52,7 +81,7 @@
52
81
  scope, # Value submitted when checked
53
82
  nil # Value submitted when unchecked (not needed here)
54
83
  %>
55
- <%= form.label "scopes_#{scope.parameterize}", scope.humanize, class: "form-check-label" %>
84
+ <%= form.label "scopes_#{scope.parameterize}", class: "form-check-label" do %><code><%= scope %></code><% end %>
56
85
  </div>
57
86
  <% end %>
58
87
  </div>
@@ -85,7 +114,7 @@
85
114
  nil # Value submitted when unchecked (not needed here)
86
115
  %>
87
116
  <%# Using the scope name directly for the label's `for` attribute requires matching the checkbox ID %>
88
- <%= form.label "scopes_#{scope.parameterize}_edit", scope.humanize, class: "form-check-label" %>
117
+ <%= form.label "scopes_#{scope.parameterize}_edit", class: "form-check-label" do %><code><%= scope %></code><% end %>
89
118
  </div>
90
119
  <% end %>
91
120
  </div>
@@ -103,4 +132,4 @@
103
132
  <%= link_to "Cancel", keys_path %>
104
133
  <% end %>
105
134
  </div>
106
- <% end %>
135
+ <% end %>
@@ -0,0 +1,20 @@
1
+ <%# Partial for displaying key action buttons (edit, revoke) %>
2
+ <%# Locals: key (required) - The ApiKey record %>
3
+
4
+ <% if key.active? %>
5
+ <%= link_to edit_key_path(key), title: "Edit Key", class: "api-keys-action-edit" do %>
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
+ <% end %>
8
+
9
+ <% if key.revocable? %>
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
+ <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
+ <% end %>
13
+ <% else %>
14
+ <span title="This key cannot be revoked" class="api-keys-action-disabled">
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
+ </span>
17
+ <% end %>
18
+ <% else %>
19
+ &mdash;
20
+ <% end %>
@@ -0,0 +1,17 @@
1
+ <%# Partial for displaying key type and environment badges %>
2
+ <%# Locals: key (required) - The ApiKey record %>
3
+
4
+ <% if key.key_type.present? %>
5
+ <% type_config = key.key_type_config %>
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' %>">
8
+ <%= key.key_type.humanize %>
9
+ </span>
10
+ <% end %>
11
+
12
+ <% if key.environment.present? %>
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' %>">
15
+ <%= key.environment.upcase %>
16
+ </span>
17
+ <% end %>
@@ -1,27 +1,25 @@
1
- <tr>
2
- <td>
3
- <%# Status indicator (no text originally, keeping it that way unless specified otherwise) %>
4
- <% if key.active? %>
5
- <span style="color: green;"></span>
6
- <% elsif key.revoked? %>
7
- <span style="color: orange;">[Revoked]</span>
8
- <% elsif key.expired? %>
9
- <span style="color: red;">[Expired]</span>
10
- <% end %>
11
- <%= key.name.presence || "Secret key" %>
1
+ <%# Partial for displaying a single API key row in the table %>
2
+ <%# Locals: key (required) - The ApiKey record %>
3
+
4
+ <tr class="api-keys-row <%= 'api-keys-row-inactive' if defined?(inactive) && inactive %>">
5
+ <td class="api-keys-cell-name">
6
+ <%= render partial: 'api_keys/keys/key_status', locals: { key: key } %>
7
+ <%= key.name.presence || (key.key_type.present? ? "#{key.key_type.humanize} key" : "API key") %>
8
+ <%= render partial: 'api_keys/keys/key_badges', locals: { key: key } %>
12
9
  </td>
13
- <td><code><%= key.masked_token %></code></td>
14
10
 
11
+ <td class="api-keys-cell-token api-keys-token-cell">
12
+ <%= render partial: 'api_keys/keys/token_display', locals: { key: key } %>
13
+ </td>
15
14
 
16
- <td title="<%= key.created_at.strftime('%Y-%m-%d %H:%M:%S %Z') %>">
15
+ <td class="api-keys-cell-created" title="<%= key.created_at.strftime('%Y-%m-%d %H:%M:%S %Z') %>">
17
16
  <%= time_ago_in_words(key.created_at) %> ago
18
17
  </td>
19
18
 
20
-
21
- <td>
19
+ <td class="api-keys-cell-expires">
22
20
  <% if key.expires_at? %>
23
21
  <% if key.expired? %>
24
- <strong style="color: red;" 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') %>">
25
23
  Expired <%= time_ago_in_words(key.expires_at) %> ago
26
24
  </strong>
27
25
  <% else %>
@@ -34,39 +32,27 @@
34
32
  <% end %>
35
33
  </td>
36
34
 
37
-
38
- <td>
35
+ <td class="api-keys-cell-last-used">
39
36
  <% if key.last_used_at? %>
40
37
  <span title="<%= key.last_used_at.strftime('%Y-%m-%d %H:%M:%S %Z') %>">
41
38
  <%= time_ago_in_words(key.last_used_at) %> ago
42
39
  </span>
43
- <%# TODO: Add relative time check (e.g., "within last 3 months") %>
44
40
  <% else %>
45
41
  <em>Never used</em>
46
42
  <% end %>
47
43
  </td>
48
44
 
49
-
50
- <td>
45
+ <td class="api-keys-cell-scopes">
51
46
  <% if key.scopes.present? %>
52
47
  <% key.scopes.each do |scope| %>
53
- <kbd class="tag is-small"><%= scope %></kbd>
48
+ <kbd class="api-keys-scope-tag tag is-small"><%= scope %></kbd>
54
49
  <% end %>
55
50
  <% else %>
56
51
  &mdash;
57
52
  <% end %>
58
53
  </td>
59
- <td class="api-keys-action-buttons">
60
- <% if key.active? %>
61
- <%= link_to api_keys.edit_key_path(key), title: "Edit Key" do %>
62
- <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>
63
- <% end %>
64
- <%= button_to api_keys.revoke_key_path(key), title: "Revoke Key", data: { turbo_method: :post, turbo_confirm: "Are you sure you want to revoke this key? It will stop working immediately." } do %>
65
- <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>
66
- <% end %>
67
- <% else %>
68
- <%# No actions available for inactive/revoked/expired keys %>
69
- &mdash;
70
- <% end %>
54
+
55
+ <td class="api-keys-cell-actions api-keys-action-buttons">
56
+ <%= render partial: 'api_keys/keys/key_actions', locals: { key: key } %>
71
57
  </td>
72
- </tr>
58
+ </tr>
@@ -0,0 +1,10 @@
1
+ <%# Partial for displaying key status indicator %>
2
+ <%# Locals: key (required) - The ApiKey record %>
3
+
4
+ <% if key.active? %>
5
+ <span class="api-keys-status api-keys-status-active"></span>
6
+ <% elsif key.revoked? %>
7
+ <span class="api-keys-status api-keys-status-revoked">[Revoked]</span>
8
+ <% elsif key.expired? %>
9
+ <span class="api-keys-status api-keys-status-expired">[Expired]</span>
10
+ <% end %>
@@ -12,7 +12,7 @@
12
12
  <thead>
13
13
  <tr>
14
14
  <th>Name</th>
15
- <th>Secret Key</th>
15
+ <th>API Key</th>
16
16
  <th>Created</th>
17
17
  <th>Expires</th>
18
18
  <th>Last Used</th>
@@ -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 %>
@@ -38,15 +35,10 @@
38
35
  </tbody>
39
36
  </table>
40
37
  <% else %>
41
- <div style="text-align: center; padding: 2em;">
42
- <h4>You don't have any API keys yet!</h4>
43
- <p>Create your first API key to get started.</p>
44
- <%# Consider adding a primary "Create Key" button here %>
45
- <%#= link_to "Create New API Key", new_key_path, class: "button primary" %>
46
- </div>
38
+ <%= render partial: 'api_keys/keys/empty_state' %>
47
39
  <% end %>
48
40
  </div>
49
41
 
50
42
  </section>
51
43
 
52
- <%# 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. %>
@@ -0,0 +1,40 @@
1
+ <%# Partial for displaying publishable keys section %>
2
+ <%# Locals: active_keys (Active publishable keys), inactive_keys (Inactive publishable keys) %>
3
+
4
+ <section class="api-keys-section api-keys-publishable-section" aria-labelledby="publishable-keys-heading">
5
+ <h2 id="publishable-keys-heading">Publishable Keys</h2>
6
+ <p class="api-keys-section-description">
7
+ These keys are safe to embed in client-side applications and browser code.
8
+ You can view them anytime.
9
+ </p>
10
+
11
+ <div class="api-keys-table-wrapper">
12
+ <% all_keys = active_keys + inactive_keys %>
13
+ <% if all_keys.any? %>
14
+ <table>
15
+ <thead>
16
+ <tr>
17
+ <th>Name</th>
18
+ <th>API Key</th>
19
+ <th>Created</th>
20
+ <th>Expires</th>
21
+ <th>Last Used</th>
22
+ <th>Permissions</th>
23
+ <th>Actions</th>
24
+ </tr>
25
+ </thead>
26
+ <tbody>
27
+ <% active_keys.each do |key| %>
28
+ <%= render partial: 'api_keys/keys/key_row', locals: { key: key } %>
29
+ <% end %>
30
+
31
+ <% inactive_keys.each do |key| %>
32
+ <%= render partial: 'api_keys/keys/key_row', locals: { key: key, inactive: true } %>
33
+ <% end %>
34
+ </tbody>
35
+ </table>
36
+ <% else %>
37
+ <%= render partial: 'api_keys/keys/empty_state', locals: { message: "No publishable keys yet." } %>
38
+ <% end %>
39
+ </div>
40
+ </section>