Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
516 changes: 516 additions & 0 deletions README.md

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions app/controllers/api_keys/keys_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ module ApiKeys
# Controller for managing API keys belonging to the current owner.
class KeysController < ApplicationController
before_action :set_api_key, only: [:show, :edit, :update, :revoke]
helper_method :key_types_feature_enabled?

# GET /keys
def index
Expand All @@ -20,6 +21,14 @@ def index
@api_keys = base_scope.active.order(created_at: :desc)
# Optionally, fetch inactive ones for a separate section or filter
@inactive_api_keys = base_scope.inactive.order(created_at: :desc)

# When key_types feature is enabled, separate publishable and secret keys
if key_types_feature_enabled?
@publishable_keys = @api_keys.select(&:public_key_type?)
@secret_keys = @api_keys.reject(&:public_key_type?)
@inactive_publishable_keys = @inactive_api_keys.select(&:public_key_type?)
@inactive_secret_keys = @inactive_api_keys.reject(&:public_key_type?)
end
end

# GET /keys/:id
Expand Down
8 changes: 8 additions & 0 deletions app/controllers/api_keys/security_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,19 @@ class SecurityController < ApplicationController
# Skip the user authentication requirement for these static pages
# as they contain general information.
skip_before_action :authenticate_api_keys_owner!, only: [:best_practices]
helper_method :key_types_feature_enabled?

# GET /security/best-practices
def best_practices
# Renders app/views/api_keys/security/best_practices.html.erb
# The view will contain the static content.
end

private

# Check if key types feature is enabled
def key_types_feature_enabled?
ApiKeys.configuration.key_types.present? && ApiKeys.configuration.key_types.any?
end
end
end
9 changes: 9 additions & 0 deletions app/views/api_keys/keys/_empty_state.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<%# Partial for displaying empty state when no keys exist %>
<%# Locals: message (optional) - Custom message to display %>

<% message ||= "You don't have any API keys yet!" %>

<div class="api-keys-empty-state" style="text-align: center; padding: 2em;">
<h4><%= message %></h4>
<p>Create your first API key to get started.</p>
</div>
20 changes: 20 additions & 0 deletions app/views/api_keys/keys/_key_actions.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<%# Partial for displaying key action buttons (edit, revoke) %>
<%# Locals: key (required) - The ApiKey record %>

<% if key.active? %>
<%= link_to api_keys.edit_key_path(key), title: "Edit Key", class: "api-keys-action-edit" do %>
<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>
<% end %>

<% if key.revocable? %>
<%= 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 %>
<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>
<% end %>
<% else %>
<span title="This key cannot be revoked" class="api-keys-action-disabled" style="color: var(--api-keys-muted-color); cursor: help;">
<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>
</span>
<% end %>
<% else %>
&mdash;
<% end %>
17 changes: 17 additions & 0 deletions app/views/api_keys/keys/_key_badges.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<%# Partial for displaying key type and environment badges %>
<%# Locals: key (required) - The ApiKey record %>

<% if key.key_type.present? %>
<% type_config = key.key_type_config %>
<% is_publishable = type_config&.dig(:revocable) == false %>
<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;">
<%= key.key_type.humanize %>
</span>
<% end %>

<% if key.environment.present? %>
<% is_live = key.environment == 'live' %>
<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;">
<%= key.environment.upcase %>
</span>
<% end %>
82 changes: 20 additions & 62 deletions app/views/api_keys/keys/_key_row.html.erb
Original file line number Diff line number Diff line change
@@ -1,48 +1,25 @@
<tr>
<td>
<%# Status indicator (no text originally, keeping it that way unless specified otherwise) %>
<% if key.active? %>
<span style="color: green;"></span>
<% elsif key.revoked? %>
<span style="color: orange;">[Revoked]</span>
<% elsif key.expired? %>
<span style="color: red;">[Expired]</span>
<% end %>
<%# Partial for displaying a single API key row in the table %>
<%# Locals: key (required) - The ApiKey record %>

<tr class="api-keys-row <%= 'api-keys-row-inactive' if defined?(inactive) && inactive %>">
<td class="api-keys-cell-name">
<%= render partial: 'api_keys/keys/key_status', locals: { key: key } %>
<%= key.name.presence || (key.key_type.present? ? "#{key.key_type.humanize} key" : "API key") %>
<%# Key type and environment badges %>
<% if key.key_type.present? %>
<% type_config = key.key_type_config %>
<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;">
<%= key.key_type.humanize %>
</span>
<% end %>
<% if key.environment.present? %>
<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;">
<%= key.environment.upcase %>
</span>
<% end %>
</td>
<td>
<code><%= key.masked_token %></code>
<% if key.public_key_type? && key.viewable_token.present? %>
<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>
<span style="display: none;">
<code style="word-break: break-all;"><%= key.viewable_token %></code>
<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>
</span>
<% end %>
<%= render partial: 'api_keys/keys/key_badges', locals: { key: key } %>
</td>

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

<td title="<%= key.created_at.strftime('%Y-%m-%d %H:%M:%S %Z') %>">
<td class="api-keys-cell-created" title="<%= key.created_at.strftime('%Y-%m-%d %H:%M:%S %Z') %>">
<%= time_ago_in_words(key.created_at) %> ago
</td>


<td>
<td class="api-keys-cell-expires">
<% if key.expires_at? %>
<% if key.expired? %>
<strong style="color: red;" title="<%= key.expires_at.strftime('%Y-%m-%d %H:%M:%S %Z') %>">
<strong style="color: var(--api-keys-status-expired-color);" title="<%= key.expires_at.strftime('%Y-%m-%d %H:%M:%S %Z') %>">
Expired <%= time_ago_in_words(key.expires_at) %> ago
</strong>
<% else %>
Expand All @@ -55,46 +32,27 @@
<% end %>
</td>


<td>
<td class="api-keys-cell-last-used">
<% if key.last_used_at? %>
<span title="<%= key.last_used_at.strftime('%Y-%m-%d %H:%M:%S %Z') %>">
<%= time_ago_in_words(key.last_used_at) %> ago
</span>
<%# TODO: Add relative time check (e.g., "within last 3 months") %>
<% else %>
<em>Never used</em>
<% end %>
</td>


<td>
<td class="api-keys-cell-scopes">
<% if key.scopes.present? %>
<% key.scopes.each do |scope| %>
<kbd class="tag is-small"><%= scope %></kbd>
<kbd class="api-keys-scope-tag tag is-small"><%= scope %></kbd>
<% end %>
<% else %>
&mdash;
<% end %>
</td>
<td class="api-keys-action-buttons">
<% if key.active? %>
<%= link_to api_keys.edit_key_path(key), title: "Edit Key" do %>
<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>
<% end %>
<%# Only show revoke button for revocable keys %>
<% if key.revocable? %>
<%= 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 %>
<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>
<% end %>
<% else %>
<span title="This key cannot be revoked" style="color: #999; cursor: help;">
<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>
</span>
<% end %>
<% else %>
<%# No actions available for inactive/revoked/expired keys %>
&mdash;
<% end %>

<td class="api-keys-cell-actions api-keys-action-buttons">
<%= render partial: 'api_keys/keys/key_actions', locals: { key: key } %>
</td>
</tr>
</tr>
10 changes: 10 additions & 0 deletions app/views/api_keys/keys/_key_status.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<%# Partial for displaying key status indicator %>
<%# Locals: key (required) - The ApiKey record %>

<% if key.active? %>
<span class="api-keys-status api-keys-status-active" style="color: var(--api-keys-status-active-color);"></span>
<% elsif key.revoked? %>
<span class="api-keys-status api-keys-status-revoked" style="color: var(--api-keys-status-revoked-color);">[Revoked]</span>
<% elsif key.expired? %>
<span class="api-keys-status api-keys-status-expired" style="color: var(--api-keys-status-expired-color);">[Expired]</span>
<% end %>
7 changes: 1 addition & 6 deletions app/views/api_keys/keys/_keys_table.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,7 @@
</tbody>
</table>
<% else %>
<div style="text-align: center; padding: 2em;">
<h4>You don't have any API keys yet!</h4>
<p>Create your first API key to get started.</p>
<%# Consider adding a primary "Create Key" button here %>
<%#= link_to "Create New API Key", new_key_path, class: "button primary" %>
</div>
<%= render partial: 'api_keys/keys/empty_state' %>
<% end %>
</div>

Expand Down
40 changes: 40 additions & 0 deletions app/views/api_keys/keys/_publishable_keys.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<%# Partial for displaying publishable keys section %>
<%# Locals: active_keys (Active publishable keys), inactive_keys (Inactive publishable keys) %>

<section class="api-keys-section api-keys-publishable-section" aria-labelledby="publishable-keys-heading">
<h2 id="publishable-keys-heading">Publishable Keys</h2>
<p class="api-keys-section-description">
These keys are safe to embed in client-side applications and browser code.
You can view them anytime.
</p>

<div class="api-keys-table-wrapper">
<% all_keys = active_keys + inactive_keys %>
<% if all_keys.any? %>
<table>
<thead>
<tr>
<th>Name</th>
<th>API Key</th>
<th>Created</th>
<th>Expires</th>
<th>Last Used</th>
<th>Permissions</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<% active_keys.each do |key| %>
<%= render partial: 'api_keys/keys/key_row', locals: { key: key } %>
<% end %>

<% inactive_keys.each do |key| %>
<%= render partial: 'api_keys/keys/key_row', locals: { key: key, inactive: true } %>
<% end %>
</tbody>
</table>
<% else %>
<%= render partial: 'api_keys/keys/empty_state', locals: { message: "No publishable keys yet." } %>
<% end %>
</div>
</section>
39 changes: 39 additions & 0 deletions app/views/api_keys/keys/_secret_keys.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<%# Partial for displaying secret keys section %>
<%# Locals: active_keys (Active secret keys), inactive_keys (Inactive secret keys) %>

<section class="api-keys-section api-keys-secret-section" aria-labelledby="secret-keys-heading">
<h2 id="secret-keys-heading">Secret Keys</h2>
<p class="api-keys-section-description">
Keep these private. Never expose in client-side code or share publicly.
</p>

<div class="api-keys-table-wrapper">
<% all_keys = active_keys + inactive_keys %>
<% if all_keys.any? %>
<table>
<thead>
<tr>
<th>Name</th>
<th>API Key</th>
<th>Created</th>
<th>Expires</th>
<th>Last Used</th>
<th>Permissions</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<% active_keys.each do |key| %>
<%= render partial: 'api_keys/keys/key_row', locals: { key: key } %>
<% end %>

<% inactive_keys.each do |key| %>
<%= render partial: 'api_keys/keys/key_row', locals: { key: key, inactive: true } %>
<% end %>
</tbody>
</table>
<% else %>
<%= render partial: 'api_keys/keys/empty_state', locals: { message: "No secret keys yet." } %>
<% end %>
</div>
</section>
11 changes: 11 additions & 0 deletions app/views/api_keys/keys/_token_display.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<%# Partial for displaying an API key token with optional show/copy functionality %>
<%# Locals: key (required) - The ApiKey record %>

<% if key.public_key_type? && key.viewable_token.present? %>
<span class="token-masked"><code><%= key.masked_token %></code></span>
<span class="token-full" style="display: none;"><code style="word-break: break-all;"><%= key.viewable_token %></code></span>
<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>
<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>
<% else %>
<code><%= key.masked_token %></code>
<% end %>
46 changes: 39 additions & 7 deletions app/views/api_keys/keys/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,44 @@

<div>

<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 %>
Learn more&nbsp;
<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>
<% end %>
<% if key_types_feature_enabled? %>
<% has_publishable_keys = @publishable_keys.any? || @inactive_publishable_keys.any? %>

<p class="api-keys-info-text">
<% if has_publishable_keys %>
<strong>Secret keys</strong> should never be shared or exposed publicly.
<strong>Publishable keys</strong> can be safely embedded in client applications.
<% else %>
Do not share your API key with others or expose it in the browser or other client-side code.
<% end %>
<%= link_to api_keys.security_best_practices_path, class: "text-primary api-keys-align-center" do %>
Learn more&nbsp;
<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>
<% end %>
</p>

<%# Render the reusable table partial %>
<%= render partial: 'keys_table', locals: { active_keys: @api_keys, inactive_keys: @inactive_api_keys } %>
<%# Render secret keys section first (primary use case) %>
<%= render partial: 'secret_keys', locals: {
active_keys: @secret_keys,
inactive_keys: @inactive_secret_keys
} %>

<%# Only render publishable keys section if there are any %>
<% if has_publishable_keys %>
<%= render partial: 'publishable_keys', locals: {
active_keys: @publishable_keys,
inactive_keys: @inactive_publishable_keys
} %>
<% end %>

<% else %>
<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 %>
Learn more&nbsp;
<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>
<% end %>

<%# Render the reusable table partial (legacy mode - single table) %>
<%= render partial: 'keys_table', locals: { active_keys: @api_keys, inactive_keys: @inactive_api_keys } %>
<% end %>

</div>
</div>
Loading