diff --git a/app/controllers/users/api_tokens_controller.rb b/app/controllers/users/api_tokens_controller.rb new file mode 100644 index 00000000..a812438d --- /dev/null +++ b/app/controllers/users/api_tokens_controller.rb @@ -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 diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb index fb543756..9561678d 100644 --- a/app/views/devise/registrations/edit.html.erb +++ b/app/views/devise/registrations/edit.html.erb @@ -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') } %> <% 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" %> diff --git a/app/views/users/api_tokens/_section.html.erb b/app/views/users/api_tokens/_section.html.erb new file mode 100644 index 00000000..f689e8b8 --- /dev/null +++ b/app/views/users/api_tokens/_section.html.erb @@ -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. %> +
+ <%= t("api_token.title") %> + <%= t("api_token.usage_hint") %> + <% if api_token.present? %> +
+ <%= t("api_token.one_time_notice") %> + <%= api_token %> +
+ <% end %> + + <%= user.api_token_digest? ? t("api_token.status_present") : t("api_token.status_absent") %> + +
+ <% 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 %> +
+
diff --git a/config/locales/en.yml b/config/locales/en.yml index 70ae62ff..8a3e2efa 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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 \" 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" diff --git a/config/locales/nb.yml b/config/locales/nb.yml index 1440d592..63fb004d 100644 --- a/config/locales/nb.yml +++ b/config/locales/nb.yml @@ -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 » 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" diff --git a/config/routes.rb b/config/routes.rb index 97c75442..021a135b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/test/controllers/users/api_tokens_controller_test.rb b/test/controllers/users/api_tokens_controller_test.rb new file mode 100644 index 00000000..07b9ac06 --- /dev/null +++ b/test/controllers/users/api_tokens_controller_test.rb @@ -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 diff --git a/test/controllers/users/registrations_controller_test.rb b/test/controllers/users/registrations_controller_test.rb new file mode 100644 index 00000000..d2eff154 --- /dev/null +++ b/test/controllers/users/registrations_controller_test.rb @@ -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