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
21 changes: 16 additions & 5 deletions engine/app/controllers/coplan/settings/tokens_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,29 @@ 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
render :index, status: :unprocessable_entity
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
Expand Down
8 changes: 8 additions & 0 deletions engine/app/views/coplan/settings/tokens/_form.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<h2>Create New Token</h2>
<%= form_with url: settings_tokens_path, method: :post, class: "form-inline" do |f| %>
<div class="form-group">
<%= f.label :name, "Token Name" %>
<%= f.text_field :name, name: "api_token[name]", placeholder: "e.g. My Agent", required: true, class: "form-control" %>
</div>
<%= f.submit "Create Token", class: "btn btn--primary" %>
<% end %>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<div class="token-reveal">
<h3>Your new API token</h3>
<p>Copy this token now. It will not be shown again.</p>
<div class="token-reveal__value">
<code><%= raw_token %></code>
</div>
</div>
24 changes: 24 additions & 0 deletions engine/app/views/coplan/settings/tokens/_token_row.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<tr id="<%= dom_id(token) %>" class="<%= 'data-table__row--muted' if token.revoked? %>">
<td>
<strong><%= token.name %></strong>
<% if token.token_prefix.present? %>
<br><code><%= token.token_prefix %>…</code>
<% end %>
</td>
<td>
<% if token.revoked? %>
<span class="badge badge--danger">Revoked</span>
<% elsif token.expired? %>
<span class="badge badge--warning">Expired</span>
<% else %>
<span class="badge badge--success">Active</span>
<% end %>
</td>
<td><%= token.last_used_at ? time_ago_in_words(token.last_used_at) + " ago" : "Never" %></td>
<td><%= token.created_at.strftime("%b %d, %Y") %></td>
<td>
<% unless token.revoked? %>
<%= button_to "Revoke", settings_token_path(token), method: :delete, class: "btn btn--danger btn--sm" %>
<% end %>
</td>
</tr>
11 changes: 11 additions & 0 deletions engine/app/views/coplan/settings/tokens/create.turbo_stream.erb
Original file line number Diff line number Diff line change
@@ -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 %>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Replace empty-state card instead of prepending missing target

When a user creates their first token, this stream prepends into tokens-list, but index.html.erb only renders <tbody id="tokens-list"> when @api_tokens.any? is true. In the empty-state path there is no target element, so Turbo ignores the prepend and the new token row does not appear until a full reload, which makes the create flow look broken for new users.

Useful? React with 👍 / 👎.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed! Removed the empty-state conditional entirely — the table always renders (with an empty tbody). The turbo_stream prepend into #tokens-list now works regardless of whether it's the first token or not.

Also added a dedicated system test for the empty-state flow to keep this covered.

<%= render "token_row", token: @api_token %>
<% end %>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<%= turbo_stream.replace @token do %>
<%= render "token_row", token: @token %>
<% end %>

<%= turbo_stream.update "token-reveal", "" %>
84 changes: 22 additions & 62 deletions engine/app/views/coplan/settings/tokens/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -3,70 +3,30 @@
<p class="page-header__subtitle">Manage API tokens for agent access</p>
</div>

<% if @raw_token.present? %>
<div class="token-reveal">
<h3>Your new API token</h3>
<p>Copy this token now. It will not be shown again.</p>
<div class="token-reveal__value">
<code><%= @raw_token %></code>
</div>
</div>
<% end %>

<div class="card">
<h2>Create New Token</h2>
<%= form_with url: settings_tokens_path, method: :post, class: "form-inline" do |f| %>
<div class="form-group">
<%= f.label :name, "Token Name" %>
<%= f.text_field :name, name: "api_token[name]", placeholder: "e.g. My Agent", required: true, class: "form-control" %>
</div>
<%= f.submit "Create Token", class: "btn btn--primary" %>
<div id="token-reveal">
<% if flash[:raw_token].present? %>
<%= render "token_reveal", raw_token: flash[:raw_token] %>
<% end %>
</div>

<div class="card">
<div class="card" id="create-token-form">
<%= render "form" %>
</div>

<div class="card" id="tokens-card">
<h2>Your Tokens</h2>
<% if @api_tokens.any? %>
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Last Used</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
<% @api_tokens.each do |token| %>
<tr class="<%= 'data-table__row--muted' if token.revoked? %>">
<td>
<strong><%= token.name %></strong>
<% if token.token_prefix.present? %>
<br><code><%= token.token_prefix %>…</code>
<% end %>
</td>
<td>
<% if token.revoked? %>
<span class="badge badge--danger">Revoked</span>
<% elsif token.expired? %>
<span class="badge badge--warning">Expired</span>
<% else %>
<span class="badge badge--success">Active</span>
<% end %>
</td>
<td><%= token.last_used_at ? time_ago_in_words(token.last_used_at) + " ago" : "Never" %></td>
<td><%= token.created_at.strftime("%b %d, %Y") %></td>
<td>
<% unless token.revoked? %>
<%= button_to "Revoke", settings_token_path(token), method: :delete, class: "btn btn--danger btn--sm" %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<p class="text-muted">No tokens yet. Create one above to get started.</p>
<% end %>
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Last Used</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody id="tokens-list">
<%= render partial: "token_row", collection: @api_tokens, as: :token %>
</tbody>
</table>
</div>
2 changes: 2 additions & 0 deletions spec/rails_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions spec/requests/api_tokens_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
6 changes: 3 additions & 3 deletions spec/requests/sessions_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions spec/support/system_test_config.rb
Original file line number Diff line number Diff line change
@@ -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 }
68 changes: 68 additions & 0 deletions spec/system/tokens_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading