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