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