Skip to content

Commit 06a72eb

Browse files
rameerezclaude
andauthored
Add headless helpers for custom dashboard integrations (#9)
* Start revamping the dashboard * Add headless helper modules for custom integrations Introduce three helper modules that make building custom API key UIs effortless without dictating styling: - TokenSession: Manages "show token once" pattern for secret keys - store(session, api_key) - save token after creation - retrieve_once(session) - get and clear token - available?(session) - check without clearing - ExpirationOptions: Handles expiration preset dropdowns - for_select - returns options for Rails select helper - parse("30_days") - converts preset to datetime - default_value - returns "no_expiration" - ViewHelpers: Data formatting for views (include in ApplicationHelper) - api_key_status/status_label/status_info - api_key_environment_label/environment_from_token - api_key_type_label/publishable?/secret? These helpers are designed to be framework-agnostic - they return data, not HTML, so integrators can use Tailwind, Bootstrap, or any styling. Co-Authored-By: Claude <noreply@anthropic.com> * Add opt-in form builder extensions for API key forms Introduce FormBuilderExtensions module that reduces view boilerplate while letting integrators control all styling. Opt-in via initializer: Rails.application.config.to_prepare do ActionView::Helpers::FormBuilder.include(ApiKeys::FormBuilderExtensions) end Form helpers included: - api_key_expiration_select(options, html_options) One-line expiration dropdown with all presets <%= form.api_key_expiration_select(class: "my-select") %> - api_key_scopes_checkboxes(scopes, checked:, &block) Block-based scope rendering - you provide markup, we handle logic <%= form.api_key_scopes_checkboxes(@scopes) do |scope, checked| %> <label><%= check_box_tag ..., checked %><%= scope %></label> <% end %> - api_key_token_data Returns structured hash for building custom token display UIs { masked:, full:, viewable:, type:, environment: } Design principle: helpers yield data and handle logic, integrators provide all HTML/CSS. Works with any design system. Co-Authored-By: Claude <noreply@anthropic.com> * Add model convenience methods and wire up helpers Model scopes on ApiKey: - .publishable - keys with key_type: "publishable" - .secret - keys that are NOT publishable (includes legacy) Owner instance methods (HasApiKeys): - available_api_key_scopes - get scopes for forms - can_create_api_key?(key_type:) - check limits before UI - create_api_key! now accepts expires_at_preset: param Auto-cleaning: - scopes= setter auto-removes blank values (form checkbox fix) - create_api_key! also cleans scopes array Wire up in lib/api_keys.rb: - Require all helper modules - Create top-level aliases for cleaner API: - ApiKeys::TokenSession (vs ApiKeys::Helpers::TokenSession) - ApiKeys::ExpirationOptions - ApiKeys::ViewHelpers Co-Authored-By: Claude <noreply@anthropic.com> * Refactor dashboard views with extracted partials Extract reusable partials from key row for cleaner code: - _token_display.html.erb - masked/viewable token with show button - _key_badges.html.erb - type, environment, expiration badges - _key_status.html.erb - active/expired/revoked status badge - _key_actions.html.erb - edit/revoke action buttons - _empty_state.html.erb - empty state messaging Update existing partials: - _key_row.html.erb - now uses extracted partials - _keys_table.html.erb - simplified table structure - _publishable_keys.html.erb - improved section layout - _secret_keys.html.erb - improved section layout Add minimal JS for show/copy token functionality: - Event delegation for .btn-show-token and .btn-copy-token - Vanilla JS, no framework dependencies - Progressive enhancement (works without JS) Co-Authored-By: Claude <noreply@anthropic.com> * Add comprehensive custom integration guide to README Document all helpers and best practices learned from production integration into a real SaaS application. New sections: - Building Custom Integrations (overview + what you'll need) - Quick Setup (4-step guide with code) - Complete Controller Example (90-line production-ready code) - Model Scopes (.publishable, .secret, .active, etc.) - Owner Instance Methods (available_api_key_scopes, can_create_api_key?, etc.) - API Key Instance Methods (full method reference) - Token Session Helper (store, retrieve_once, available?) - Expiration Options Helper (for_select, parse, default_value) - Form Builder Extensions (with ERB examples) - View Helpers (status, type, environment helpers) - View Examples (index, form, success page code) - Best Practices (7 production-learned patterns) The guide provides copy-paste-ready code for building custom UIs while using the gem's model layer and business logic. Co-Authored-By: Claude <noreply@anthropic.com> * Add tests for headless helper modules Cover TokenSession, ExpirationOptions, and ViewHelpers to bring branch coverage above the 60% CI threshold. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add input validation and edge case protections Address automated review feedback: - Add max length check (500 chars) in api_key_environment_from_token - Add max days limit (3650 / ~10 years) in ExpirationOptions.parse - Document can_create_api_key? as best-effort check for UI purposes - Add thorough boundary condition tests for all edge cases Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1407c81 commit 06a72eb

25 files changed

Lines changed: 2080 additions & 131 deletions

README.md

Lines changed: 516 additions & 0 deletions
Large diffs are not rendered by default.

app/controllers/api_keys/keys_controller.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ module ApiKeys
44
# Controller for managing API keys belonging to the current owner.
55
class KeysController < ApplicationController
66
before_action :set_api_key, only: [:show, :edit, :update, :revoke]
7+
helper_method :key_types_feature_enabled?
78

89
# GET /keys
910
def index
@@ -20,6 +21,14 @@ def index
2021
@api_keys = base_scope.active.order(created_at: :desc)
2122
# Optionally, fetch inactive ones for a separate section or filter
2223
@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
2332
end
2433

2534
# GET /keys/:id

app/controllers/api_keys/security_controller.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,19 @@ class SecurityController < ApplicationController
66
# Skip the user authentication requirement for these static pages
77
# as they contain general information.
88
skip_before_action :authenticate_api_keys_owner!, only: [:best_practices]
9+
helper_method :key_types_feature_enabled?
910

1011
# GET /security/best-practices
1112
def best_practices
1213
# Renders app/views/api_keys/security/best_practices.html.erb
1314
# The view will contain the static content.
1415
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
1523
end
1624
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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" style="text-align: center; padding: 2em;">
7+
<h4><%= message %></h4>
8+
<p>Create your first API key to get started.</p>
9+
</div>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 api_keys.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 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 %>
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" style="color: var(--api-keys-muted-color); cursor: help;">
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 %>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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' %>" 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;">
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' %>" 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;">
15+
<%= key.environment.upcase %>
16+
</span>
17+
<% end %>
Lines changed: 20 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +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 %>
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 } %>
117
<%= key.name.presence || (key.key_type.present? ? "#{key.key_type.humanize} key" : "API key") %>
12-
<%# Key type and environment badges %>
13-
<% if key.key_type.present? %>
14-
<% type_config = key.key_type_config %>
15-
<span style="display: inline-block; padding: 2px 6px; font-size: 0.75em; border-radius: 3px; background-color: <%= type_config&.dig(:revocable) == false ? '#fef3cd' : '#e7f1ff' %>; color: <%= type_config&.dig(:revocable) == false ? '#856404' : '#004085' %>; margin-left: 4px;">
16-
<%= key.key_type.humanize %>
17-
</span>
18-
<% end %>
19-
<% if key.environment.present? %>
20-
<span style="display: inline-block; padding: 2px 6px; font-size: 0.75em; border-radius: 3px; background-color: <%= key.environment == 'live' ? '#d4edda' : '#f8d7da' %>; color: <%= key.environment == 'live' ? '#155724' : '#721c24' %>; margin-left: 4px;">
21-
<%= key.environment.upcase %>
22-
</span>
23-
<% end %>
24-
</td>
25-
<td>
26-
<code><%= key.masked_token %></code>
27-
<% if key.public_key_type? && key.viewable_token.present? %>
28-
<button type="button" onclick="this.nextElementSibling.style.display='inline'; this.style.display='none';" style="margin-left: 8px; font-size: 0.75em; padding: 2px 6px; cursor: pointer;" title="Show full token">Show</button>
29-
<span style="display: none;">
30-
<code style="word-break: break-all;"><%= key.viewable_token %></code>
31-
<button type="button" onclick="navigator.clipboard.writeText('<%= key.viewable_token %>'); alert('Copied!');" style="margin-left: 4px; font-size: 0.75em; padding: 2px 6px; cursor: pointer;" title="Copy to clipboard">Copy</button>
32-
</span>
33-
<% end %>
8+
<%= render partial: 'api_keys/keys/key_badges', locals: { key: key } %>
349
</td>
3510

11+
<td class="api-keys-cell-token api-keys-token-cell">
12+
<%= render partial: 'api_keys/keys/token_display', locals: { key: key } %>
13+
</td>
3614

37-
<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') %>">
3816
<%= time_ago_in_words(key.created_at) %> ago
3917
</td>
4018

41-
42-
<td>
19+
<td class="api-keys-cell-expires">
4320
<% if key.expires_at? %>
4421
<% if key.expired? %>
45-
<strong style="color: red;" title="<%= key.expires_at.strftime('%Y-%m-%d %H:%M:%S %Z') %>">
22+
<strong style="color: var(--api-keys-status-expired-color);" title="<%= key.expires_at.strftime('%Y-%m-%d %H:%M:%S %Z') %>">
4623
Expired <%= time_ago_in_words(key.expires_at) %> ago
4724
</strong>
4825
<% else %>
@@ -55,46 +32,27 @@
5532
<% end %>
5633
</td>
5734

58-
59-
<td>
35+
<td class="api-keys-cell-last-used">
6036
<% if key.last_used_at? %>
6137
<span title="<%= key.last_used_at.strftime('%Y-%m-%d %H:%M:%S %Z') %>">
6238
<%= time_ago_in_words(key.last_used_at) %> ago
6339
</span>
64-
<%# TODO: Add relative time check (e.g., "within last 3 months") %>
6540
<% else %>
6641
<em>Never used</em>
6742
<% end %>
6843
</td>
6944

70-
71-
<td>
45+
<td class="api-keys-cell-scopes">
7246
<% if key.scopes.present? %>
7347
<% key.scopes.each do |scope| %>
74-
<kbd class="tag is-small"><%= scope %></kbd>
48+
<kbd class="api-keys-scope-tag tag is-small"><%= scope %></kbd>
7549
<% end %>
7650
<% else %>
7751
&mdash;
7852
<% end %>
7953
</td>
80-
<td class="api-keys-action-buttons">
81-
<% if key.active? %>
82-
<%= link_to api_keys.edit_key_path(key), title: "Edit Key" do %>
83-
<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>
84-
<% end %>
85-
<%# Only show revoke button for revocable keys %>
86-
<% if key.revocable? %>
87-
<%= 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 %>
88-
<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>
89-
<% end %>
90-
<% else %>
91-
<span title="This key cannot be revoked" style="color: #999; cursor: help;">
92-
<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>
93-
</span>
94-
<% end %>
95-
<% else %>
96-
<%# No actions available for inactive/revoked/expired keys %>
97-
&mdash;
98-
<% end %>
54+
55+
<td class="api-keys-cell-actions api-keys-action-buttons">
56+
<%= render partial: 'api_keys/keys/key_actions', locals: { key: key } %>
9957
</td>
100-
</tr>
58+
</tr>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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" style="color: var(--api-keys-status-active-color);"></span>
6+
<% elsif key.revoked? %>
7+
<span class="api-keys-status api-keys-status-revoked" style="color: var(--api-keys-status-revoked-color);">[Revoked]</span>
8+
<% elsif key.expired? %>
9+
<span class="api-keys-status api-keys-status-expired" style="color: var(--api-keys-status-expired-color);">[Expired]</span>
10+
<% end %>

app/views/api_keys/keys/_keys_table.html.erb

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,7 @@
3838
</tbody>
3939
</table>
4040
<% 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>
41+
<%= render partial: 'api_keys/keys/empty_state' %>
4742
<% end %>
4843
</div>
4944

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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>

0 commit comments

Comments
 (0)