defmodule AutoAdminUser do @moduledoc """ Bootstrap an admin account on app startup from an environment variable. Runs as a oneshot `Task` in your supervision tree. On startup it reads an environment variable (default `ADMIN_EMAIL`), checks if a user with that email already exists, and creates one with a random password if not. ## Quick start (standard phx.gen.auth) # application.ex children = [ MyApp.Repo, {AutoAdminUser, context: MyApp.Accounts}, MyAppWeb.Endpoint ] This will: 1. Read the `ADMIN_EMAIL` environment variable 2. Call `MyApp.Accounts.get_user_by_email/1` to check if the user exists 3. If not, call `MyApp.Accounts.register_user/1` with a random 32-char password 4. Log the result ## Options * `:context` - Your accounts context module (e.g. `MyApp.Accounts`). Required unless both `:find` and `:create` are provided as functions. * `:env_var` - Environment variable name to read. Default: `"ADMIN_EMAIL"`. * `:find` - How to look up an existing user. Can be: * An atom — called as a function on `:context` (default: `:get_user_by_email`) * A 1-arity function — called with the email Must return `nil` (not found) or a user struct. * `:create` - How to create the user. Can be: * An atom — called as a function on `:context` (default: `:register_user`) * A 1-arity function — called with `%{email: email, password: password}` Must return `{:ok, user}` or `{:error, reason}`. * `:after_create` - Optional 1-arity function called with the newly created user. Runs after successful creation, before logging. * `:password_length` - Length of the random password. Default: `32`. ## Examples # Custom create function name (e.g. Mack uses create_admin) {AutoAdminUser, context: MyApp.Accounts, create: :create_admin} # Custom after_create hook (e.g. send password reset email) {AutoAdminUser, context: MyApp.Accounts, after_create: fn user -> MyApp.Accounts.deliver_user_reset_password_instructions( user, &"/users/reset_password/\#{&1}") end} # Fully custom find/create (no context module needed) {AutoAdminUser, env_var: "SUPERADMIN_EMAIL", find: fn email -> MyApp.Repo.get_by(MyApp.User, email: email) end, create: fn params -> MyApp.Repo.insert(MyApp.User.changeset(%MyApp.User{}, params)) end} """ use Task, restart: :temporary require Logger @default_env_var "ADMIN_EMAIL" @default_find :get_user_by_email @default_create :register_user @default_password_length 32 @doc false def start_link(opts) when is_list(opts) do Task.start_link(__MODULE__, :bootstrap, [opts]) end @doc """ Bootstraps the admin account. Called automatically by the supervision tree. You shouldn't need to call this directly. """ def bootstrap(opts) do env_var = Keyword.get(opts, :env_var, @default_env_var) case System.get_env(env_var) do nil -> Logger.debug("[AutoAdminUser] #{env_var} not set, skipping") "" -> Logger.debug("[AutoAdminUser] #{env_var} is empty, skipping") email -> ensure_admin(email, opts) end end defp ensure_admin(email, opts) do case find_user(email, opts) do nil -> create_admin(email, opts) _user -> Logger.debug("[AutoAdminUser] User already exists for #{email}") end end defp create_admin(email, opts) do password_length = Keyword.get(opts, :password_length, @default_password_length) password = random_password(password_length) params = %{email: email, password: password} case create_user(params, opts) do {:ok, user} -> run_after_create(user, opts) Logger.info("[AutoAdminUser] Bootstrapped admin account for #{email}") {:error, reason} -> Logger.warning("[AutoAdminUser] Failed to create admin for #{email}: #{inspect(reason)}") end end defp find_user(email, opts) do case Keyword.get(opts, :find, @default_find) do fun when is_function(fun, 1) -> fun.(email) name when is_atom(name) -> context = fetch_context!(opts) apply(context, name, [email]) end end defp create_user(params, opts) do case Keyword.get(opts, :create, @default_create) do fun when is_function(fun, 1) -> fun.(params) name when is_atom(name) -> context = fetch_context!(opts) apply(context, name, [params]) end end defp run_after_create(user, opts) do case Keyword.get(opts, :after_create) do nil -> :ok fun when is_function(fun, 1) -> fun.(user) end end defp fetch_context!(opts) do Keyword.get(opts, :context) || raise ArgumentError, "AutoAdminUser requires :context option (e.g. MyApp.Accounts) " <> "or explicit :find and :create functions" end @doc """ Generates a cryptographically random password of the given length. """ def random_password(length) when length >= 16 do :crypto.strong_rand_bytes(length) |> Base.url_encode64(padding: false) |> binary_part(0, length) end end