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
14 changes: 14 additions & 0 deletions app/controllers/users/api_tokens_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class Users::ApiTokensController < AuthenticatedController
def create
authorize! current_user, to: :regenerate_token?, with: UserPolicy

# The plaintext token exists only in this response (a turbo_stream swap of
# the section, 200 OK) — it is never placed in the flash/session/cookies.
@api_token = current_user.regenerate_api_token!
render turbo_stream: turbo_stream.replace(
:api_token_section,
partial: "users/api_tokens/section",
locals: { user: current_user, api_token: @api_token }
)
end
end
3 changes: 3 additions & 0 deletions app/views/devise/registrations/edit.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
<%= link_to t('registration.cancel_account'), registration_path(resource_name), class: "text-red-600 hover:underline", data: { turbo_method: :delete, turbo_confirm: t('notice.are_you_sure') } %>
</div>
<% end %>

<%= render "users/api_tokens/section", user: resource, api_token: @api_token %>

<%= link_to t("common.back"), root_path, class: "mt-3 underline hover:text-primary" %>
</div>
</div>
26 changes: 26 additions & 0 deletions app/views/users/api_tokens/_section.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<%# One-time token display: api_token is only ever non-nil in the response to
the generate/regenerate action — it is never persisted or shown again. %>
<div id="api_token_section" class="flex flex-col">
<span class="text-xl font-semibold text-gray-600 mt-8"><%= t("api_token.title") %></span>
<span class="text-sm text-gray-400"><%= t("api_token.usage_hint") %></span>
<% if api_token.present? %>
<div class="flex flex-col gap-y-2 mt-2 p-4 bg-yellow-50 border border-yellow-300 rounded-md">
<span class="text-sm font-semibold text-yellow-800"><%= t("api_token.one_time_notice") %></span>
<code class="font-mono text-sm select-all break-all"><%= api_token %></code>
</div>
<% end %>
<span class="text-sm text-gray-600 mt-2">
<%= user.api_token_digest? ? t("api_token.status_present") : t("api_token.status_absent") %>
</span>
<div class="mt-2">
<% if user.api_token_digest? %>
<%= render ButtonComponent.new(variant: :outline, method: :post, path: users_api_token_path, form: { data: { turbo_confirm: t("api_token.regenerate_confirm") } }) do %>
<%= t("api_token.regenerate") %>
<% end %>
<% else %>
<%= render ButtonComponent.new(variant: :outline, method: :post, path: users_api_token_path) do %>
<%= t("api_token.generate") %>
<% end %>
<% end %>
</div>
</div>
10 changes: 10 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,16 @@ en:
cannot_be_undone: "This action cannot be undone."


api_token:
title: "API token"
usage_hint: "Use the token as \"Authorization: Bearer <token>\" against the API at /api/v1/."
status_present: "An API token exists for your account."
status_absent: "No API token has been generated yet."
generate: "Generate API token"
regenerate: "Regenerate API token"
regenerate_confirm: "Are you sure? Your old API token will stop working immediately."
one_time_notice: "Save this token now — it will not be shown again."

registration:
first_name: "First name"
last_name: "Last name"
Expand Down
10 changes: 10 additions & 0 deletions config/locales/nb.yml
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,16 @@ nb:
cannot_be_undone: "Denne handlingen kan ikke bli tatt tilbake."


api_token:
title: "API-token"
usage_hint: "Bruk tokenet som «Authorization: Bearer <token>» mot API-et på /api/v1/."
status_present: "Det finnes et API-token for kontoen din."
status_absent: "Det er ikke generert noe API-token ennå."
generate: "Generer API-token"
regenerate: "Generer nytt API-token"
regenerate_confirm: "Er du sikker? Det gamle API-tokenet slutter å virke umiddelbart."
one_time_notice: "Lagre dette tokenet nå — det vises ikke igjen."

registration:
first_name: "Fornavn"
last_name: "Etternavn"
Expand Down
4 changes: 4 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
root to: "time_regs#index", as: :authenticated_root
end

namespace :users do
resource :api_token, only: :create
end

root to: redirect("users/sign_in")

get "privacy-policy", to: "privacy_policy#index", as: :privacy_policy
Expand Down
38 changes: 38 additions & 0 deletions test/controllers/users/api_tokens_controller_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
require "test_helper"

class Users::ApiTokensControllerTest < ActionController::TestCase
def setup
@user = users(:ron)
end

test "unauthenticated create redirects to sign in" do
post :create

assert_redirected_to new_user_session_path
assert_nil @user.reload.api_token_digest
end

test "create changes digest and shows token exactly once with warning" do
sign_in @user

post :create

assert_response :ok
assert_equal "text/vnd.turbo-stream.html", response.media_type
token = assigns(:api_token)
assert token.present?
assert_equal 1, response.body.scan(token).count
assert_includes response.body, I18n.t("api_token.one_time_notice")
assert_equal Digest::SHA256.hexdigest(token), @user.reload.api_token_digest
end

test "regenerating invalidates previous token" do
old_token = @user.regenerate_api_token!
sign_in @user

post :create

assert_nil User.find_by_api_token(old_token)
assert_equal @user, User.find_by_api_token(assigns(:api_token))
end
end
27 changes: 27 additions & 0 deletions test/controllers/users/registrations_controller_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
require "test_helper"

class Users::RegistrationsControllerTest < ActionController::TestCase
def setup
@request.env["devise.mapping"] = Devise.mappings[:user]
@user = users(:ron)
sign_in @user
end

test "edit page never shows a token on plain visit" do
@user.regenerate_api_token!

get :edit

assert_response :success
assert_not_includes response.body, I18n.t("api_token.one_time_notice")
assert_includes response.body, I18n.t("api_token.regenerate")
end

test "edit page offers to generate a token when none exists" do
get :edit

assert_response :success
assert_includes response.body, I18n.t("api_token.status_absent")
assert_includes response.body, I18n.t("api_token.generate")
end
end
Loading