From 420472ead18ad011cd2c9d8684f58b03ae675c40 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Tue, 10 Mar 2026 18:09:43 -0400 Subject: [PATCH] Use Turbo Streams for token create/destroy and add system tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tokens controller was returning a redirect after create, which Turbo Drive rejected with 'Form responses must redirect to another location' because the redirect target matched the form action URL. The token reveal section and flash never appeared — the form just stayed stuck in a busy state. Fix by responding with Turbo Streams for both create and destroy actions: - create: updates the token reveal, resets the form, prepends the new row - destroy: replaces the row in-place with revoked state Extract shared partials (_token_row, _token_reveal, _form) so there is zero HTML duplication between the index view and turbo_stream templates. Add system tests covering both create and revoke flows to catch Turbo integration issues that request specs miss. Update existing request specs to match the new redirect behavior. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019cd872-3abe-7556-8927-9966fe044b6c --- .../coplan/settings/tokens_controller.rb | 21 +++-- .../coplan/settings/tokens/_form.html.erb | 8 ++ .../settings/tokens/_token_reveal.html.erb | 7 ++ .../settings/tokens/_token_row.html.erb | 24 ++++++ .../settings/tokens/create.turbo_stream.erb | 11 +++ .../settings/tokens/destroy.turbo_stream.erb | 5 ++ .../coplan/settings/tokens/index.html.erb | 84 +++++-------------- spec/rails_helper.rb | 2 + spec/requests/api_tokens_spec.rb | 5 +- spec/requests/sessions_spec.rb | 6 +- spec/support/system_test_config.rb | 9 ++ spec/system/tokens_spec.rb | 68 +++++++++++++++ 12 files changed, 178 insertions(+), 72 deletions(-) create mode 100644 engine/app/views/coplan/settings/tokens/_form.html.erb create mode 100644 engine/app/views/coplan/settings/tokens/_token_reveal.html.erb create mode 100644 engine/app/views/coplan/settings/tokens/_token_row.html.erb create mode 100644 engine/app/views/coplan/settings/tokens/create.turbo_stream.erb create mode 100644 engine/app/views/coplan/settings/tokens/destroy.turbo_stream.erb create mode 100644 spec/support/system_test_config.rb create mode 100644 spec/system/tokens_spec.rb 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 @@

Manage API tokens for agent access

-<% 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? %> - - - - - - - - - - - - <% @api_tokens.each do |token| %> - - - - - - - - <% end %> - -
NameStatusLast UsedCreated
- <%= 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 %> -
- <% else %> -

No tokens yet. Create one above to get started.

- <% end %> + + + + + + + + + + + + <%= render partial: "token_row", collection: @api_tokens, as: :token %> + +
NameStatusLast UsedCreated
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