railspress-engine 1.2.1 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/railspress/admin.js +54 -0
  3. data/app/assets/stylesheets/railspress/admin/buttons.css +12 -0
  4. data/app/assets/stylesheets/railspress/admin/cards.css +8 -0
  5. data/app/assets/stylesheets/railspress/admin/forms.css +21 -0
  6. data/app/controllers/railspress/admin/agent_bootstrap_keys_controller.rb +109 -0
  7. data/app/controllers/railspress/admin/api_keys_controller.rb +165 -0
  8. data/app/controllers/railspress/admin/base_controller.rb +61 -1
  9. data/app/controllers/railspress/api/v1/agent_key_exchanges_controller.rb +50 -0
  10. data/app/controllers/railspress/api/v1/base_controller.rb +52 -0
  11. data/app/controllers/railspress/api/v1/categories_controller.rb +89 -0
  12. data/app/controllers/railspress/api/v1/concerns/post_serialization.rb +130 -0
  13. data/app/controllers/railspress/api/v1/post_header_image_contexts_controller.rb +158 -0
  14. data/app/controllers/railspress/api/v1/post_header_image_focal_points_controller.rb +74 -0
  15. data/app/controllers/railspress/api/v1/post_header_images_controller.rb +58 -0
  16. data/app/controllers/railspress/api/v1/post_imports_controller.rb +118 -0
  17. data/app/controllers/railspress/api/v1/posts_controller.rb +127 -0
  18. data/app/controllers/railspress/api/v1/prime_controller.rb +78 -0
  19. data/app/controllers/railspress/api/v1/tags_controller.rb +85 -0
  20. data/app/helpers/railspress/admin_helper.rb +19 -0
  21. data/app/models/railspress/agent_bootstrap_key.rb +163 -0
  22. data/app/models/railspress/api_key.rb +157 -0
  23. data/app/models/railspress/post_export_processor.rb +16 -2
  24. data/app/views/railspress/admin/agent_bootstrap_keys/_form.html.erb +25 -0
  25. data/app/views/railspress/admin/agent_bootstrap_keys/new.html.erb +7 -0
  26. data/app/views/railspress/admin/agent_bootstrap_keys/reveal.html.erb +38 -0
  27. data/app/views/railspress/admin/api_keys/_form.html.erb +25 -0
  28. data/app/views/railspress/admin/api_keys/index.html.erb +142 -0
  29. data/app/views/railspress/admin/api_keys/new.html.erb +7 -0
  30. data/app/views/railspress/admin/api_keys/reveal.html.erb +40 -0
  31. data/app/views/railspress/admin/posts/_form.html.erb +1 -1
  32. data/app/views/railspress/admin/posts/_post_row.html.erb +1 -1
  33. data/app/views/railspress/admin/posts/show.html.erb +1 -1
  34. data/app/views/railspress/admin/shared/_copyable_textarea.html.erb +17 -0
  35. data/app/views/railspress/admin/shared/_sidebar.html.erb +14 -0
  36. data/config/routes.rb +33 -0
  37. data/db/migrate/20260415000001_create_railspress_api_keys.rb +40 -0
  38. data/db/migrate/20260415000002_create_railspress_agent_bootstrap_keys.rb +37 -0
  39. data/lib/generators/railspress/install/templates/initializer.rb +16 -0
  40. data/lib/railspress/engine.rb +12 -0
  41. data/lib/railspress/version.rb +1 -1
  42. data/lib/railspress.rb +73 -1
  43. metadata +26 -1
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Api
5
+ module V1
6
+ class PrimeController < BaseController
7
+ def show
8
+ render json: {
9
+ data: {
10
+ service: "Railspress API",
11
+ version: "v1",
12
+ authentication: {
13
+ type: "bearer",
14
+ token_types: [ "api_key", "agent_bootstrap_key" ],
15
+ agent_bootstrap_exchange_endpoint: exchange_api_v1_agent_keys_path
16
+ },
17
+ defaults: {
18
+ post_status: "draft",
19
+ publish_with_explicit_status: true
20
+ },
21
+ capabilities: capabilities,
22
+ endpoints: endpoints,
23
+ key: {
24
+ id: current_api_key.id,
25
+ name: current_api_key.name,
26
+ status: current_api_key.status,
27
+ expires_at: current_api_key.expires_at,
28
+ last_used_at: current_api_key.last_used_at
29
+ },
30
+ server_time: Time.current
31
+ }
32
+ }
33
+ end
34
+
35
+ private
36
+
37
+ def capabilities
38
+ {
39
+ posts: {
40
+ list: true,
41
+ read: true,
42
+ create: true,
43
+ update: true,
44
+ delete: true,
45
+ rich_text_html: true,
46
+ default_status: "draft",
47
+ publish_supported: true
48
+ },
49
+ post_imports: {
50
+ create: true,
51
+ read: true,
52
+ formats: %w[md markdown txt zip]
53
+ },
54
+ post_media: {
55
+ header_image: Railspress.post_images_enabled?,
56
+ focal_points: Railspress.post_images_enabled? && Railspress.focal_points_enabled?,
57
+ context_overrides: Railspress.post_images_enabled? && Railspress.focal_points_enabled?
58
+ },
59
+ taxonomies: {
60
+ categories: true,
61
+ tags: true
62
+ }
63
+ }
64
+ end
65
+
66
+ def endpoints
67
+ {
68
+ prime: api_v1_prime_path,
69
+ posts: api_v1_posts_path,
70
+ post_imports: api_v1_post_imports_path,
71
+ categories: api_v1_categories_path,
72
+ tags: api_v1_tags_path
73
+ }
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Api
5
+ module V1
6
+ class TagsController < BaseController
7
+ before_action :set_tag, only: [ :show, :update, :destroy ]
8
+
9
+ def index
10
+ tags = Railspress::Tag.ordered
11
+ total_count = tags.count
12
+ tags = tags.offset((page - 1) * per_page).limit(per_page)
13
+
14
+ render json: {
15
+ data: tags.map { |tag| serialize_tag(tag) },
16
+ meta: {
17
+ page: page,
18
+ per: per_page,
19
+ total_count: total_count,
20
+ total_pages: (total_count.to_f / per_page).ceil
21
+ }
22
+ }
23
+ end
24
+
25
+ def show
26
+ render json: { data: serialize_tag(@tag) }
27
+ end
28
+
29
+ def create
30
+ tag = Railspress::Tag.new(tag_params)
31
+
32
+ if tag.save
33
+ render json: { data: serialize_tag(tag) }, status: :created
34
+ else
35
+ render_validation_errors(tag)
36
+ end
37
+ end
38
+
39
+ def update
40
+ if @tag.update(tag_params)
41
+ render json: { data: serialize_tag(@tag) }
42
+ else
43
+ render_validation_errors(@tag)
44
+ end
45
+ end
46
+
47
+ def destroy
48
+ @tag.destroy
49
+ head :no_content
50
+ end
51
+
52
+ private
53
+
54
+ def set_tag
55
+ @tag = Railspress::Tag.find(params[:id])
56
+ end
57
+
58
+ def tag_params
59
+ params.require(:tag).permit(:name, :slug)
60
+ end
61
+
62
+ def page
63
+ [ params.fetch(:page, 1).to_i, 1 ].max
64
+ end
65
+
66
+ def per_page
67
+ requested = params.fetch(:per, 20).to_i
68
+ requested = 20 if requested <= 0
69
+ [ requested, 100 ].min
70
+ end
71
+
72
+ def serialize_tag(tag)
73
+ {
74
+ id: tag.id,
75
+ name: tag.name,
76
+ slug: tag.slug,
77
+ posts_count: tag.posts.count,
78
+ created_at: tag.created_at,
79
+ updated_at: tag.updated_at
80
+ }
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -664,6 +664,25 @@ module Railspress
664
664
  content_tag(:span, text, class: "rp-badge rp-badge--#{status}")
665
665
  end
666
666
 
667
+ # Returns a safe display string for an author record in admin views.
668
+ # Falls back through common attributes if configured display method is unavailable.
669
+ def rp_author_display(author)
670
+ return nil unless author
671
+
672
+ configured_method = Railspress.author_display_method
673
+ if configured_method.present? && author.respond_to?(configured_method)
674
+ configured_value = author.public_send(configured_method)
675
+ return configured_value if configured_value.present?
676
+ end
677
+
678
+ fallback_method = [ :name, :full_name, :display_name, :email, :email_address ]
679
+ .find { |method| author.respond_to?(method) && author.public_send(method).present? }
680
+
681
+ return author.public_send(fallback_method) if fallback_method
682
+
683
+ "Author ##{author.id || "unknown"}"
684
+ end
685
+
667
686
  # Renders a hint/help text below a form input.
668
687
  # @param text [String] the hint text
669
688
  # @return [String] rendered HTML
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Railspress
6
+ class AgentBootstrapKey < ApplicationRecord
7
+ self.table_name = "railspress_agent_bootstrap_keys"
8
+
9
+ DEFAULT_TTL = 1.hour
10
+
11
+ class ExchangeError < StandardError; end
12
+
13
+ belongs_to :owner, polymorphic: true, optional: true
14
+ belongs_to :created_by, polymorphic: true, optional: true
15
+ belongs_to :revoked_by, polymorphic: true, optional: true
16
+ belongs_to :exchanged_api_key, class_name: "Railspress::ApiKey", optional: true
17
+
18
+ encrypts :secret_ciphertext
19
+
20
+ normalizes :name, with: ->(value) { value.to_s.strip }
21
+ normalizes :token_prefix, with: ->(value) { value.to_s.downcase }
22
+
23
+ validates :name, presence: true, length: { maximum: 120 }
24
+ validates :token_prefix, presence: true, length: { is: 12 }
25
+ validates :token_digest, presence: true, uniqueness: true, length: { is: 64 }
26
+ validates :secret_ciphertext, presence: true
27
+ validates :expires_at, presence: true
28
+ validates :global_uuid, presence: true, uniqueness: true
29
+
30
+ scope :recent, -> { order(created_at: :desc) }
31
+ scope :revoked, -> { where.not(revoked_at: nil) }
32
+ scope :used, -> { where.not(used_at: nil) }
33
+ scope :expired, -> { where("expires_at <= ?", Time.current) }
34
+ scope :active, -> {
35
+ where(revoked_at: nil, used_at: nil)
36
+ .where("expires_at > ?", Time.current)
37
+ }
38
+
39
+ before_validation :set_global_uuid, on: :create
40
+
41
+ class << self
42
+ def issue!(name:, actor:, owner: actor, expires_at: DEFAULT_TTL.from_now)
43
+ prefix = generate_prefix
44
+ raw_secret = generate_secret
45
+ token = build_token(prefix, raw_secret)
46
+
47
+ bootstrap_key = create!(
48
+ name: name,
49
+ token_prefix: prefix,
50
+ token_digest: digest(raw_secret),
51
+ secret_ciphertext: raw_secret,
52
+ owner: owner,
53
+ created_by: actor,
54
+ expires_at: expires_at
55
+ )
56
+
57
+ [ bootstrap_key, token ]
58
+ end
59
+
60
+ def authenticate(raw_token)
61
+ parsed = parse_token(raw_token)
62
+ return nil unless parsed
63
+
64
+ candidate = active.where(token_prefix: parsed[:prefix]).recent.first
65
+ return nil unless candidate
66
+ return nil unless secure_digest_match?(candidate.token_digest, digest(parsed[:secret]))
67
+
68
+ candidate
69
+ end
70
+
71
+ def build_token(prefix, raw_secret)
72
+ "rpb_#{Rails.env}_#{prefix}_#{raw_secret}"
73
+ end
74
+
75
+ def digest(raw_secret)
76
+ OpenSSL::HMAC.hexdigest("SHA256", digest_key, raw_secret.to_s)
77
+ end
78
+
79
+ def parse_token(raw_token)
80
+ return nil if raw_token.blank?
81
+
82
+ match = raw_token.match(/\Arpb_[a-z0-9_]+_([a-f0-9]{12})_([a-f0-9]{64})\z/)
83
+ return nil unless match
84
+
85
+ { prefix: match[1], secret: match[2] }
86
+ end
87
+
88
+ private
89
+
90
+ def digest_key
91
+ Rails.application.secret_key_base.to_s
92
+ end
93
+
94
+ def generate_prefix
95
+ SecureRandom.hex(6)
96
+ end
97
+
98
+ def generate_secret
99
+ SecureRandom.hex(32)
100
+ end
101
+
102
+ def secure_digest_match?(stored_digest, candidate_digest)
103
+ return false if stored_digest.blank? || candidate_digest.blank?
104
+ return false unless stored_digest.bytesize == candidate_digest.bytesize
105
+
106
+ ActiveSupport::SecurityUtils.secure_compare(stored_digest, candidate_digest)
107
+ end
108
+ end
109
+
110
+ def exchange!(ip_address: nil, api_key_name: default_api_key_name, api_key_expires_at: nil)
111
+ raise ExchangeError, "Bootstrap key is not active." unless active?
112
+
113
+ transaction do
114
+ api_key_actor = owner || created_by
115
+ api_key_owner = owner || api_key_actor
116
+ api_key, plain_token = Railspress::ApiKey.issue!(
117
+ name: api_key_name,
118
+ actor: api_key_actor,
119
+ owner: api_key_owner,
120
+ expires_at: api_key_expires_at
121
+ )
122
+
123
+ update!(
124
+ used_at: Time.current,
125
+ used_ip: ip_address,
126
+ exchanged_api_key: api_key
127
+ )
128
+
129
+ [ api_key, plain_token ]
130
+ end
131
+ end
132
+
133
+ def revoke!(actor:, reason: "revoked")
134
+ update!(
135
+ revoked_at: Time.current,
136
+ revoke_reason: reason,
137
+ revoked_by: actor
138
+ )
139
+ end
140
+
141
+ def active?
142
+ revoked_at.nil? && used_at.nil? && expires_at > Time.current
143
+ end
144
+
145
+ def status
146
+ return "revoked" if revoked_at.present?
147
+ return "used" if used_at.present?
148
+ return "expired" if expires_at <= Time.current
149
+
150
+ "active"
151
+ end
152
+
153
+ private
154
+
155
+ def default_api_key_name
156
+ "#{name} (API Key)"
157
+ end
158
+
159
+ def set_global_uuid
160
+ self.global_uuid ||= SecureRandom.uuid
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Railspress
6
+ class ApiKey < ApplicationRecord
7
+ self.table_name = "railspress_api_keys"
8
+
9
+ belongs_to :owner, polymorphic: true, optional: true
10
+ belongs_to :created_by, polymorphic: true, optional: true
11
+ belongs_to :rotated_by, polymorphic: true, optional: true
12
+ belongs_to :revoked_by, polymorphic: true, optional: true
13
+ belongs_to :rotated_from, class_name: "Railspress::ApiKey", optional: true
14
+
15
+ encrypts :secret_ciphertext
16
+
17
+ normalizes :name, with: ->(value) { value.to_s.strip }
18
+ normalizes :token_prefix, with: ->(value) { value.to_s.downcase }
19
+
20
+ validates :name, presence: true, length: { maximum: 120 }
21
+ validates :token_prefix, presence: true, length: { is: 12 }
22
+ validates :token_digest, presence: true, uniqueness: true, length: { is: 64 }
23
+ validates :secret_ciphertext, presence: true
24
+ validates :global_uuid, presence: true, uniqueness: true
25
+
26
+ scope :recent, -> { order(created_at: :desc) }
27
+ scope :revoked, -> { where.not(revoked_at: nil) }
28
+ scope :expired, -> { where("expires_at <= ?", Time.current) }
29
+ scope :active, -> {
30
+ where(revoked_at: nil)
31
+ .where("expires_at IS NULL OR expires_at > ?", Time.current)
32
+ }
33
+
34
+ before_validation :set_global_uuid, on: :create
35
+
36
+ class << self
37
+ def issue!(name:, actor:, owner: actor, expires_at: nil, rotated_from: nil)
38
+ prefix = generate_prefix
39
+ raw_secret = generate_secret
40
+ token = build_token(prefix, raw_secret)
41
+
42
+ api_key = create!(
43
+ name: name,
44
+ token_prefix: prefix,
45
+ token_digest: digest(raw_secret),
46
+ secret_ciphertext: raw_secret,
47
+ owner: owner,
48
+ created_by: actor,
49
+ expires_at: expires_at,
50
+ rotated_from: rotated_from
51
+ )
52
+
53
+ [ api_key, token ]
54
+ end
55
+
56
+ def authenticate(raw_token, ip_address: nil)
57
+ parsed = parse_token(raw_token)
58
+ return nil unless parsed
59
+
60
+ candidate = active.where(token_prefix: parsed[:prefix]).recent.first
61
+ return nil unless candidate
62
+ return nil unless secure_digest_match?(candidate.token_digest, digest(parsed[:secret]))
63
+
64
+ candidate.touch_usage!(ip_address: ip_address)
65
+ candidate
66
+ end
67
+
68
+ def build_token(prefix, raw_secret)
69
+ "rp_#{Rails.env}_#{prefix}_#{raw_secret}"
70
+ end
71
+
72
+ def digest(raw_secret)
73
+ OpenSSL::HMAC.hexdigest("SHA256", digest_key, raw_secret.to_s)
74
+ end
75
+
76
+ def parse_token(raw_token)
77
+ return nil if raw_token.blank?
78
+
79
+ match = raw_token.match(/\Arp_[a-z0-9_]+_([a-f0-9]{12})_([a-f0-9]{64})\z/)
80
+ return nil unless match
81
+
82
+ { prefix: match[1], secret: match[2] }
83
+ end
84
+
85
+ private
86
+
87
+ def digest_key
88
+ Rails.application.secret_key_base.to_s
89
+ end
90
+
91
+ def generate_prefix
92
+ SecureRandom.hex(6)
93
+ end
94
+
95
+ def generate_secret
96
+ SecureRandom.hex(32)
97
+ end
98
+
99
+ def secure_digest_match?(stored_digest, candidate_digest)
100
+ return false if stored_digest.blank? || candidate_digest.blank?
101
+ return false unless stored_digest.bytesize == candidate_digest.bytesize
102
+
103
+ ActiveSupport::SecurityUtils.secure_compare(stored_digest, candidate_digest)
104
+ end
105
+ end
106
+
107
+ def revoke!(actor:, reason: "revoked")
108
+ update!(
109
+ revoked_at: Time.current,
110
+ revoke_reason: reason,
111
+ revoked_by: actor
112
+ )
113
+ end
114
+
115
+ def rotate!(actor:, name: self.name, expires_at: self.expires_at)
116
+ transaction do
117
+ replacement_key, plain_token = self.class.issue!(
118
+ name: name,
119
+ actor: actor,
120
+ owner: owner,
121
+ expires_at: expires_at,
122
+ rotated_from: self
123
+ )
124
+
125
+ update!(
126
+ revoked_at: Time.current,
127
+ revoke_reason: "rotated",
128
+ revoked_by: actor,
129
+ rotated_by: actor
130
+ )
131
+
132
+ [ replacement_key, plain_token ]
133
+ end
134
+ end
135
+
136
+ def active?
137
+ revoked_at.nil? && (expires_at.nil? || expires_at > Time.current)
138
+ end
139
+
140
+ def status
141
+ return "revoked" if revoked_at.present?
142
+ return "expired" if expires_at.present? && expires_at <= Time.current
143
+
144
+ "active"
145
+ end
146
+
147
+ def touch_usage!(ip_address: nil)
148
+ update_columns(last_used_at: Time.current, last_used_ip: ip_address, updated_at: Time.current)
149
+ end
150
+
151
+ private
152
+
153
+ def set_global_uuid
154
+ self.global_uuid ||= SecureRandom.uuid
155
+ end
156
+ end
157
+ end
@@ -85,8 +85,7 @@ module Railspress
85
85
  fm["meta_description"] = post.meta_description if post.meta_description.present?
86
86
 
87
87
  if Railspress.authors_enabled? && post.respond_to?(:author) && post.author.present?
88
- display_method = Railspress.author_display_method
89
- fm["author"] = post.author.public_send(display_method)
88
+ fm["author"] = author_display_for_export(post.author)
90
89
  end
91
90
 
92
91
  if post.header_image.attached?
@@ -158,5 +157,20 @@ module Railspress
158
157
  zipfile.add(entry_name, file)
159
158
  end
160
159
  end
160
+
161
+ def author_display_for_export(author)
162
+ display_method = Railspress.author_display_method
163
+ if display_method.present? && author.respond_to?(display_method)
164
+ configured_value = author.public_send(display_method)
165
+ return configured_value if configured_value.present?
166
+ end
167
+
168
+ fallback_method = [ :name, :full_name, :display_name, :email, :email_address ]
169
+ .find { |method| author.respond_to?(method) && author.public_send(method).present? }
170
+
171
+ return author.public_send(fallback_method) if fallback_method
172
+
173
+ "Author ##{author.id || "unknown"}"
174
+ end
161
175
  end
162
176
  end
@@ -0,0 +1,25 @@
1
+ <%= form_with model: [:admin, agent_bootstrap_key], class: "rp-form rp-form--narrow" do |form| %>
2
+ <%= rp_form_errors(agent_bootstrap_key) %>
3
+
4
+ <%= rp_hint("Bootstrap keys are one-time exchange tokens for agents. They default to a 1-hour expiration.") %>
5
+
6
+ <%= rp_string_field(
7
+ form,
8
+ :name,
9
+ primary: true,
10
+ required: true,
11
+ label: "Bootstrap Key Name",
12
+ placeholder: "e.g., Claude Code",
13
+ hint: "Use a descriptive name so you can identify this bootstrap key later."
14
+ ) %>
15
+
16
+ <%= rp_datetime_field(
17
+ form,
18
+ :expires_at,
19
+ label: "Expiration",
20
+ hint: "Defaults to 1 hour from now. Set a custom date/time to override.",
21
+ step: 60
22
+ ) %>
23
+
24
+ <%= rp_form_actions(form, admin_api_keys_path, submit_text: "Create Agent Bootstrap Key") %>
25
+ <% end %>
@@ -0,0 +1,7 @@
1
+ <% content_for :title, "New Agent Key" %>
2
+
3
+ <h1 class="rp-page-title rp-page-title--standalone">New Agent Key</h1>
4
+
5
+ <div class="rp-card rp-card--padded">
6
+ <%= render "form", agent_bootstrap_key: @agent_bootstrap_key %>
7
+ </div>
@@ -0,0 +1,38 @@
1
+ <% content_for :title, "Agent Bootstrap Key Created" %>
2
+
3
+ <h1 class="rp-page-title rp-page-title--standalone">Agent Bootstrap Key Created</h1>
4
+
5
+ <div class="rp-card rp-card--padded">
6
+ <div class="rp-form rp-form--spacious">
7
+ <p class="rp-hint">
8
+ Copy this bootstrap key now. It is one-time use and will not be shown again.
9
+ </p>
10
+
11
+ <%= render "railspress/admin/shared/copyable_textarea",
12
+ id: "rp-bootstrap-key-token",
13
+ label: "Bootstrap Token",
14
+ value: @plain_bootstrap_token,
15
+ rows: 4,
16
+ hint: "Use this once at #{exchange_api_v1_agent_keys_path} to mint a real API key.",
17
+ button_text: "Copy Bootstrap Token" %>
18
+
19
+ <%= render "railspress/admin/shared/copyable_textarea",
20
+ id: "rp-bootstrap-quick-start",
21
+ label: "Bootstrap Quick Start",
22
+ value: @bootstrap_quick_start,
23
+ rows: 6,
24
+ button_text: "Copy Quick Start" %>
25
+
26
+ <%= render "railspress/admin/shared/copyable_textarea",
27
+ id: "rp-bootstrap-instructions",
28
+ label: "Agent Setup Instructions",
29
+ value: @bootstrap_instructions,
30
+ rows: 15,
31
+ button_text: "Copy Instructions" %>
32
+
33
+ <div class="rp-form-actions">
34
+ <%= link_to "Create Another Bootstrap Key", new_admin_agent_bootstrap_key_path, class: "rp-btn rp-btn--primary" %>
35
+ <%= link_to "Back to Agents & API", admin_api_keys_path, class: "rp-btn rp-btn--secondary" %>
36
+ </div>
37
+ </div>
38
+ </div>
@@ -0,0 +1,25 @@
1
+ <%= form_with model: [:admin, api_key], class: "rp-form rp-form--narrow" do |form| %>
2
+ <%= rp_form_errors(api_key) %>
3
+
4
+ <%= rp_hint("API keys currently grant full access to API resources that are exposed in this version.") %>
5
+
6
+ <%= rp_string_field(
7
+ form,
8
+ :name,
9
+ primary: true,
10
+ required: true,
11
+ label: "API Key Name",
12
+ placeholder: "e.g., Production Sync Worker",
13
+ hint: "Use a descriptive name so you can identify this key later."
14
+ ) %>
15
+
16
+ <%= rp_datetime_field(
17
+ form,
18
+ :expires_at,
19
+ label: "Expiration",
20
+ hint: "Default is no expiration. Set a custom date/time if you want this key to expire.",
21
+ step: 60
22
+ ) %>
23
+
24
+ <%= rp_form_actions(form, admin_api_keys_path, submit_text: "Create API Key") %>
25
+ <% end %>