Merge pull request 'Allow creating accounts without a password in the AdminAPI, update docs' (#7937) from mkljczk/pleroma:admin-api-passwordless-user-creation into develop

Reviewed-on: https://git.pleroma.social/pleroma/pleroma/pulls/7937
This commit is contained in:
lain 2026-07-27 07:17:14 +00:00
commit 8ab0e046b1
8 changed files with 478 additions and 39 deletions

View file

@ -8,13 +8,17 @@ defmodule Pleroma.Web.AdminAPI.UserController do
import Pleroma.Web.ControllerHelper,
only: [fetch_integer_param: 3]
alias Ecto.Multi
alias Pleroma.ModerationLog
alias Pleroma.PasswordResetToken
alias Pleroma.User
alias Pleroma.Web.ActivityPub.Builder
alias Pleroma.Web.ActivityPub.Pipeline
alias Pleroma.Web.AdminAPI
alias Pleroma.Web.AdminAPI.Search
alias Pleroma.Web.Endpoint
alias Pleroma.Web.Plugs.OAuthScopesPlug
alias Pleroma.Web.Router
@users_page_size 50
@ -148,9 +152,25 @@ defmodule Pleroma.Web.AdminAPI.UserController do
} = conn,
_
) do
changesets =
conn =
if Enum.any?(users, fn user -> not Map.has_key?(user, :password) end) do
put_private(conn, :skip_idempotency_cache, true)
else
conn
end
multi =
users
|> Enum.map(fn %{nickname: nickname, email: email, password: password} ->
|> Enum.map(fn %{nickname: nickname, email: email} = attrs ->
{password, passwordless?} =
case Map.fetch(attrs, :password) do
{:ok, password} ->
{password, false}
:error ->
{:crypto.strong_rand_bytes(16) |> Base.encode64(), true}
end
user_data = %{
nickname: nickname,
name: nickname,
@ -160,44 +180,80 @@ defmodule Pleroma.Web.AdminAPI.UserController do
bio: "."
}
User.register_changeset(%User{}, user_data, need_confirmation: false)
{User.register_changeset(%User{}, user_data, confirmed: true), passwordless?}
end)
|> Enum.reduce(Ecto.Multi.new(), fn changeset, multi ->
Ecto.Multi.insert(multi, Ecto.UUID.generate(), changeset)
|> Enum.reduce(Multi.new(), fn {changeset, passwordless?}, multi ->
user_operation = {:user, Ecto.UUID.generate()}
multi = Multi.insert(multi, user_operation, changeset)
if passwordless? do
Multi.run(
multi,
{:password_reset_token, Ecto.UUID.generate()},
fn _repo, changes ->
changes
|> Map.fetch!(user_operation)
|> PasswordResetToken.create_token()
end
)
else
multi
end
end)
case Pleroma.Repo.transaction(changesets) do
{:ok, users_map} ->
users =
users_map
case Pleroma.Repo.transaction(multi) do
{:ok, results} ->
{users, password_reset_tokens} =
results
|> Map.values()
|> Enum.split_with(&match?(%User{}, &1))
users =
users
|> Enum.map(fn user ->
{:ok, user} = User.post_register_action(user)
user
end)
password_reset_links =
Map.new(password_reset_tokens, fn token ->
{token.user_id, Router.Helpers.reset_password_url(Endpoint, :reset, token.token)}
end)
ModerationLog.insert_log(%{
actor: admin,
subjects: users,
action: "create"
})
render(conn, "created_many.json", users: users)
render(conn, "created_many.json",
users: users,
password_reset_links: password_reset_links
)
{:error, id, changeset, _} ->
{:error, {:user, _} = id, %Ecto.Changeset{} = changeset, _} ->
changesets =
Enum.map(changesets.operations, fn
Enum.flat_map(multi.operations, fn
{^id, {:changeset, _current_changeset, _}} ->
changeset
[changeset]
{_, {:changeset, current_changeset, _}} ->
current_changeset
{{:user, _}, {:changeset, current_changeset, _}} ->
[current_changeset]
_ ->
[]
end)
conn
|> put_status(:conflict)
|> render("create_errors.json", changesets: changesets)
{:error, {:password_reset_token, _}, _reason, _} ->
Pleroma.Web.AdminAPI.FallbackController.call(
conn,
{:password_reset_token_error, :creation_failed}
)
end
end

View file

@ -88,18 +88,29 @@ defmodule Pleroma.Web.AdminAPI.AccountView do
}
end
def render("created_many.json", %{users: users}) do
render_many(users, AccountView, "created.json", as: :user)
def render("created_many.json", %{users: users} = assigns) do
password_reset_links = Map.get(assigns, :password_reset_links, %{})
render_many(users, AccountView, "created.json",
as: :user,
password_reset_links: password_reset_links
)
end
def render("created.json", %{user: user}) do
%{
type: "success",
code: 200,
data: %{
def render("created.json", %{user: user} = assigns) do
password_reset_links = Map.get(assigns, :password_reset_links, %{})
data =
%{
nickname: user.nickname,
email: user.email
}
|> maybe_put_password_reset_link(Map.get(password_reset_links, user.id))
%{
type: "success",
code: 200,
data: data
}
end
@ -119,6 +130,11 @@ defmodule Pleroma.Web.AdminAPI.AccountView do
}
end
defp maybe_put_password_reset_link(data, nil), do: data
defp maybe_put_password_reset_link(data, link),
do: Map.put(data, :password_reset_link, link)
def merge_account_views(%User{} = user) do
MastodonAPI.AccountView.render("show.json", %{user: user, skip_visibility_check: true})
|> Map.merge(AdminAPI.AccountView.render("show.json", %{user: user}))

View file

@ -82,8 +82,15 @@ defmodule Pleroma.Web.ApiSpec.Admin.UserOperation do
properties: %{
nickname: %Schema{type: :string},
email: %Schema{type: :string},
password: %Schema{type: :string}
}
password: %Schema{
type: :string,
description:
"Optional. When omitted, a `password_reset_link` is returned, " <>
"allowing the user to set their own password. When provided, " <>
"it must not be empty."
}
},
required: [:nickname, :email]
}
}
}
@ -102,7 +109,8 @@ defmodule Pleroma.Web.ApiSpec.Admin.UserOperation do
type: :object,
properties: %{
email: %Schema{type: :string, format: :email},
nickname: %Schema{type: :string}
nickname: %Schema{type: :string},
password_reset_link: %Schema{type: :string}
}
}
}

View file

@ -6,6 +6,8 @@ defmodule Pleroma.Web.Plugs.IdempotencyPlug do
import Phoenix.Controller, only: [json: 2]
import Plug.Conn
alias Pleroma.Config
@behaviour Plug
@cachex Pleroma.Config.get([:cachex, :provider], Cachex)
@ -27,9 +29,11 @@ defmodule Pleroma.Web.Plugs.IdempotencyPlug do
def call(conn, _), do: conn
def process_request(conn, key) do
case @cachex.get(:idempotency_cache, key) do
cache_key = {conn.method, conn.request_path, actor_key(conn), key}
case @cachex.get(:idempotency_cache, cache_key) do
{:ok, nil} ->
cache_resposnse(conn, key)
cache_resposnse(conn, key, cache_key)
{:ok, record} ->
send_cached(conn, key, record)
@ -39,18 +43,48 @@ defmodule Pleroma.Web.Plugs.IdempotencyPlug do
end
end
defp cache_resposnse(conn, key) do
register_before_send(conn, fn conn ->
[request_id] = get_resp_header(conn, "x-request-id")
content_type = get_content_type(conn)
defp cache_resposnse(conn, key, cache_key) do
register_before_send(conn, &maybe_cache_response(&1, key, cache_key))
end
record = {request_id, content_type, conn.status, conn.resp_body}
{:ok, _} = @cachex.put(:idempotency_cache, key, record)
defp maybe_cache_response(
%{private: %{skip_idempotency_cache: true}} = conn,
_key,
_cache_key
),
do: conn
conn
|> put_resp_header("idempotency-key", key)
|> put_resp_header("x-original-request-id", request_id)
end)
defp maybe_cache_response(conn, key, cache_key) do
[request_id] = get_resp_header(conn, "x-request-id")
content_type = get_content_type(conn)
record = {request_id, content_type, conn.status, conn.resp_body}
{:ok, _} = @cachex.put(:idempotency_cache, cache_key, record)
conn
|> put_resp_header("idempotency-key", key)
|> put_resp_header("x-original-request-id", request_id)
end
defp actor_key(%{assigns: assigns}) do
{principal_key(assigns), token_key(assigns[:token]), staff_privileges()}
end
defp principal_key(%{user: %{id: id} = user}) do
{:user, id, Map.get(user, :is_admin), Map.get(user, :is_moderator)}
end
defp principal_key(%{app: %{id: id}}), do: {:app, id}
defp principal_key(_assigns), do: nil
defp token_key(%{id: id} = token), do: {id, token |> Map.get(:scopes, []) |> Enum.sort()}
defp token_key(_token), do: nil
defp staff_privileges do
{
Config.get([:instance, :admin_privileges], []) |> Enum.sort(),
Config.get([:instance, :moderator_privileges], []) |> Enum.sort()
}
end
defp send_cached(conn, key, record) do