diff --git a/engine/app/controllers/coplan/settings/tokens_controller.rb b/engine/app/controllers/coplan/settings/tokens_controller.rb
index 6eb8bca..b94f477 100644
--- a/engine/app/controllers/coplan/settings/tokens_controller.rb
+++ b/engine/app/controllers/coplan/settings/tokens_controller.rb
@@ -8,8 +8,15 @@ def index
def create
@api_token, @raw_token = ApiToken.create_with_raw_token(user: current_user, name: params[:api_token][:name])
@api_tokens = current_user.api_tokens.order(created_at: :desc)
- flash.now[:notice] = "Token created. Copy it now — it won't be shown again."
- render :index
+
+ respond_to do |format|
+ format.turbo_stream
+ format.html do
+ flash[:raw_token] = @raw_token
+ flash[:notice] = "Token created. Copy it now — it won't be shown again."
+ redirect_to settings_tokens_path, status: :see_other
+ end
+ end
rescue ActiveRecord::RecordInvalid => e
@api_tokens = current_user.api_tokens.order(created_at: :desc)
flash.now[:alert] = e.message
@@ -17,9 +24,13 @@ def create
end
def destroy
- token = current_user.api_tokens.find(params[:id])
- token.revoke!
- redirect_to settings_tokens_path, notice: "Token revoked."
+ @token = current_user.api_tokens.find(params[:id])
+ @token.revoke!
+
+ respond_to do |format|
+ format.turbo_stream
+ format.html { redirect_to settings_tokens_path, notice: "Token revoked.", status: :see_other }
+ end
end
end
end
diff --git a/engine/app/views/coplan/settings/tokens/_form.html.erb b/engine/app/views/coplan/settings/tokens/_form.html.erb
new file mode 100644
index 0000000..c1fad38
--- /dev/null
+++ b/engine/app/views/coplan/settings/tokens/_form.html.erb
@@ -0,0 +1,8 @@
+
Create New Token
+<%= form_with url: settings_tokens_path, method: :post, class: "form-inline" do |f| %>
+
+ <%= f.label :name, "Token Name" %>
+ <%= f.text_field :name, name: "api_token[name]", placeholder: "e.g. My Agent", required: true, class: "form-control" %>
+
+ <%= f.submit "Create Token", class: "btn btn--primary" %>
+<% end %>
diff --git a/engine/app/views/coplan/settings/tokens/_token_reveal.html.erb b/engine/app/views/coplan/settings/tokens/_token_reveal.html.erb
new file mode 100644
index 0000000..1b12de6
--- /dev/null
+++ b/engine/app/views/coplan/settings/tokens/_token_reveal.html.erb
@@ -0,0 +1,7 @@
+
+
Your new API token
+
Copy this token now. It will not be shown again.
+
+ <%= raw_token %>
+
+
diff --git a/engine/app/views/coplan/settings/tokens/_token_row.html.erb b/engine/app/views/coplan/settings/tokens/_token_row.html.erb
new file mode 100644
index 0000000..a21bb08
--- /dev/null
+++ b/engine/app/views/coplan/settings/tokens/_token_row.html.erb
@@ -0,0 +1,24 @@
+
+
+ <%= token.name %>
+ <% if token.token_prefix.present? %>
+
<%= token.token_prefix %>…
+ <% end %>
+ |
+
+ <% if token.revoked? %>
+ Revoked
+ <% elsif token.expired? %>
+ Expired
+ <% else %>
+ Active
+ <% end %>
+ |
+ <%= token.last_used_at ? time_ago_in_words(token.last_used_at) + " ago" : "Never" %> |
+ <%= token.created_at.strftime("%b %d, %Y") %> |
+
+ <% unless token.revoked? %>
+ <%= button_to "Revoke", settings_token_path(token), method: :delete, class: "btn btn--danger btn--sm" %>
+ <% end %>
+ |
+
diff --git a/engine/app/views/coplan/settings/tokens/create.turbo_stream.erb b/engine/app/views/coplan/settings/tokens/create.turbo_stream.erb
new file mode 100644
index 0000000..4d0ff8d
--- /dev/null
+++ b/engine/app/views/coplan/settings/tokens/create.turbo_stream.erb
@@ -0,0 +1,11 @@
+<%= turbo_stream.update "token-reveal" do %>
+ <%= render "token_reveal", raw_token: @raw_token %>
+<% end %>
+
+<%= turbo_stream.update "create-token-form" do %>
+ <%= render "form" %>
+<% end %>
+
+<%= turbo_stream.prepend "tokens-list" do %>
+ <%= render "token_row", token: @api_token %>
+<% end %>
diff --git a/engine/app/views/coplan/settings/tokens/destroy.turbo_stream.erb b/engine/app/views/coplan/settings/tokens/destroy.turbo_stream.erb
new file mode 100644
index 0000000..ef0e765
--- /dev/null
+++ b/engine/app/views/coplan/settings/tokens/destroy.turbo_stream.erb
@@ -0,0 +1,5 @@
+<%= turbo_stream.replace @token do %>
+ <%= render "token_row", token: @token %>
+<% end %>
+
+<%= turbo_stream.update "token-reveal", "" %>
diff --git a/engine/app/views/coplan/settings/tokens/index.html.erb b/engine/app/views/coplan/settings/tokens/index.html.erb
index b0249d0..6f3db1f 100644
--- a/engine/app/views/coplan/settings/tokens/index.html.erb
+++ b/engine/app/views/coplan/settings/tokens/index.html.erb
@@ -3,70 +3,30 @@
-<% if @raw_token.present? %>
-
-
Your new API token
-
Copy this token now. It will not be shown again.
-
- <%= @raw_token %>
-
-
-<% end %>
-
-
-
Create New Token
- <%= form_with url: settings_tokens_path, method: :post, class: "form-inline" do |f| %>
-
- <%= f.label :name, "Token Name" %>
- <%= f.text_field :name, name: "api_token[name]", placeholder: "e.g. My Agent", required: true, class: "form-control" %>
-
- <%= f.submit "Create Token", class: "btn btn--primary" %>
+
+ <% if flash[:raw_token].present? %>
+ <%= render "token_reveal", raw_token: flash[:raw_token] %>
<% end %>
-
+
+ <%= render "form" %>
+
+
+
Your Tokens
- <% if @api_tokens.any? %>
-
-
-
- | Name |
- Status |
- Last Used |
- Created |
- |
-
-
-
- <% @api_tokens.each do |token| %>
-
-
- <%= token.name %>
- <% if token.token_prefix.present? %>
-
<%= token.token_prefix %>…
- <% end %>
- |
-
- <% if token.revoked? %>
- Revoked
- <% elsif token.expired? %>
- Expired
- <% else %>
- Active
- <% end %>
- |
- <%= token.last_used_at ? time_ago_in_words(token.last_used_at) + " ago" : "Never" %> |
- <%= token.created_at.strftime("%b %d, %Y") %> |
-
- <% unless token.revoked? %>
- <%= button_to "Revoke", settings_token_path(token), method: :delete, class: "btn btn--danger btn--sm" %>
- <% end %>
- |
-
- <% end %>
-
-
- <% else %>
-
No tokens yet. Create one above to get started.
- <% end %>
+
+
+
+ | Name |
+ Status |
+ Last Used |
+ Created |
+ |
+
+
+
+ <%= render partial: "token_row", collection: @api_tokens, as: :token %>
+
+
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index bc491fd..2706b70 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -4,6 +4,8 @@
abort("The Rails environment is running in production mode!") if Rails.env.production?
require "rspec/rails"
+Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }
+
begin
ActiveRecord::Migration.maintain_test_schema!
rescue ActiveRecord::PendingMigrationError => e
diff --git a/spec/requests/api_tokens_spec.rb b/spec/requests/api_tokens_spec.rb
index f9392f6..db019c3 100644
--- a/spec/requests/api_tokens_spec.rb
+++ b/spec/requests/api_tokens_spec.rb
@@ -16,7 +16,8 @@
expect {
post settings_tokens_path, params: { api_token: { name: "Test Token" } }
}.to change(CoPlan::ApiToken, :count).by(1)
- expect(response).to have_http_status(:success)
+ expect(response).to redirect_to(settings_tokens_path)
+ follow_redirect!
expect(response.body).to include("token-reveal")
end
@@ -39,6 +40,6 @@
it "requires authentication" do
delete sign_out_path
get settings_tokens_path
- expect(response).to have_http_status(:unauthorized)
+ expect(response).to redirect_to(sign_in_path)
end
end
diff --git a/spec/requests/sessions_spec.rb b/spec/requests/sessions_spec.rb
index 0dc0d09..c4a28df 100644
--- a/spec/requests/sessions_spec.rb
+++ b/spec/requests/sessions_spec.rb
@@ -30,11 +30,11 @@
expect(response).to redirect_to(sign_in_path)
get root_path
- expect(response).to have_http_status(:unauthorized)
+ expect(response).to redirect_to(sign_in_path)
end
- it "unauthenticated access returns unauthorized" do
+ it "unauthenticated access redirects to sign in" do
get root_path
- expect(response).to have_http_status(:unauthorized)
+ expect(response).to redirect_to(sign_in_path)
end
end
diff --git a/spec/support/system_test_config.rb b/spec/support/system_test_config.rb
new file mode 100644
index 0000000..2ebd38f
--- /dev/null
+++ b/spec/support/system_test_config.rb
@@ -0,0 +1,9 @@
+require "capybara/rspec"
+
+RSpec.configure do |config|
+ config.before(:each, type: :system) do
+ driven_by :selenium_chrome_headless
+ end
+end
+
+Capybara.server = :puma, { Silent: true }
diff --git a/spec/system/tokens_spec.rb b/spec/system/tokens_spec.rb
new file mode 100644
index 0000000..7104ede
--- /dev/null
+++ b/spec/system/tokens_spec.rb
@@ -0,0 +1,68 @@
+require "rails_helper"
+
+RSpec.describe "Token management", type: :system do
+ before do
+ visit sign_in_path
+ fill_in "Email address", with: "testuser@example.com"
+ click_button "Sign In"
+ expect(page).to have_content("Sign out")
+ end
+
+ it "creates a token and displays the raw value via Turbo Stream" do
+ visit settings_tokens_path
+
+ # Verify no token reveal is shown initially
+ expect(page).not_to have_css(".token-reveal")
+
+ fill_in "Token Name", with: "My Test Token"
+ click_button "Create Token"
+
+ # The token reveal should appear without a full page reload
+ expect(page).to have_css(".token-reveal")
+ expect(page).to have_content("Your new API token")
+ expect(page).to have_content("Copy this token now")
+
+ # The raw token value should be a 64-char hex string
+ token_code = find(".token-reveal__value code")
+ expect(token_code.text).to match(/\A[0-9a-f]{64}\z/)
+
+ # The new token should appear in the table
+ expect(page).to have_content("My Test Token")
+
+ # The form should be reset and ready for another token
+ expect(find_field("Token Name").value).to be_blank
+ end
+
+ it "creates a token when no tokens exist yet (empty state)" do
+ visit settings_tokens_path
+
+ # Table should be empty
+ expect(page).not_to have_css("#tokens-list tr")
+
+ fill_in "Token Name", with: "First Token"
+ click_button "Create Token"
+
+ # Token reveal and table row should both appear
+ expect(page).to have_css(".token-reveal")
+ expect(page).to have_content("First Token")
+ expect(page).to have_css("#tokens-list tr", count: 1)
+ end
+
+ it "revokes a token via Turbo Stream" do
+ user = CoPlan::User.find_by!(email: "testuser@example.com")
+ create(:api_token, user: user, name: "Revokable")
+
+ visit settings_tokens_path
+ expect(page).to have_content("Revokable")
+ expect(page).to have_css(".badge--success")
+
+ click_button "Revoke"
+
+ # Should update in-place to show revoked state
+ expect(page).to have_css(".badge--danger")
+ expect(page).not_to have_button("Revoke")
+
+ # Token name should still be visible (not removed from page)
+ expect(page).to have_content("Revokable")
+ end
+end