Skip to main content

lib/mix/tasks/skua.gen.auth.ex

defmodule Mix.Tasks.Skua.Gen.Auth do
  @shortdoc "Scaffold authentication on top of phx.gen.auth (magic-link / OTP / password+OTP / custom)"
  @moduledoc """
  Generates an authentication system by running `mix phx.gen.auth` and then
  applying Skua's flow-specific changes. The generated code is yours to edit.

      mix skua.gen.auth --auth magic_link   # default — Phoenix's magic-link flow
      mix skua.gen.auth --auth otp          # 6-digit one-time-code login
      mix skua.gen.auth --auth password_otp # password + OTP, both on the login screen
      mix skua.gen.auth --auth custom       # stock phx.gen.auth, nothing extra

  Flags:

    * `--otp-length` (otp/password_otp) — code length, default 6 (8 recommended
      for high-value apps).
    * `--otp-expiry` — code validity in minutes, default 10.

  Idempotent: re-running detects an existing `Accounts` context and skips the
  base generation.
  """
  use Mix.Task

  @flows ~w(magic_link otp password_otp custom)
  @switches [auth: :string, otp_length: :integer, otp_expiry: :integer, yes: :boolean]

  @impl Mix.Task
  def run(args) do
    {opts, _, _} = OptionParser.parse(args, switches: @switches)
    flow = opts[:auth] || "magic_link"

    cond do
      flow not in @flows ->
        Mix.raise("Unknown --auth #{inspect(flow)}. Use one of: #{Enum.join(@flows, ", ")}.")

      Mix.Project.umbrella?() ->
        Mix.raise("Run `mix skua.gen.auth` from inside your *_web app, not the umbrella root.")

      true ->
        Mix.shell().info([:cyan, "\n  skua.gen.auth — #{flow}\n", :reset])
        ensure_phx_gen_auth!()
        apply_flow(flow, opts)
    end
  end

  # Run phx.gen.auth once (idempotent: skip if the Accounts context already exists).
  defp ensure_phx_gen_auth! do
    app = Mix.Project.config()[:app]

    if File.exists?(Path.join(["lib", "#{app}", "accounts.ex"])) do
      Mix.shell().info([:faint, "  • Accounts context exists — skipping phx.gen.auth", :reset])
    else
      run_phx_gen_auth()
    end
  end

  defp run_phx_gen_auth do
    Mix.shell().info("  running `mix phx.gen.auth Accounts User users`…")
    previous = Mix.shell()
    Mix.shell(Skua.Auth.YesShell)

    try do
      Mix.Task.run("phx.gen.auth", ~w(Accounts User users))
    after
      Mix.shell(previous)
    end
  end

  # --- per-flow ------------------------------------------------------------

  alias Skua.Auth.Patches, as: AP

  defp apply_flow("custom", _opts) do
    print_results(dev_setup_steps(app_context()))

    done("""
    Stock phx.gen.auth generated — your closest-to-Phoenix starting point.
    Add Sign in with Google / other providers yourself from here.

    In dev, the DB is created + migrated automatically on `mix phx.server`
    (skua_auto_setup in config/dev.exs); test/prod are unaffected.

    Next: mix deps.get && mix ecto.migrate
    """)
  end

  defp apply_flow("magic_link", _opts) do
    # Stock phx.gen.auth IS the magic-link flow. We only add a production email
    # provider (Resend) so the magic links actually deliver off-box, while dev
    # and test keep Swoosh's local mailbox untouched.
    ctx = app_context()

    print_results(mailer_steps(ctx) ++ dev_setup_steps(ctx))

    done("""
    Magic-link auth generated (Phoenix default) with a Resend production mailer.
    Dev and test still use Swoosh's local mailbox; prod reads RESEND_API_KEY
    (see .env.example) at runtime, so dev/test boot with no key required.

    Next: mix deps.get && mix ecto.migrate
    """)
  end

  defp apply_flow("otp", opts), do: gen_otp(opts, :otp)
  defp apply_flow("password_otp", opts), do: gen_otp(opts, :password_otp)

  # Resolve the app's module/path naming once.
  defp app_context do
    app = to_string(Mix.Project.config()[:app])
    app_mod = Macro.camelize(app)

    %{
      app: app,
      app_mod: app_mod,
      web: app_mod <> "Web",
      accounts_mod: app_mod <> ".Accounts",
      repo: app_mod <> ".Repo",
      app_atom: ":" <> app,
      acc: Path.join("lib", app),
      web_dir: Path.join("lib", "#{app}_web"),
      live: Path.join(["lib", "#{app}_web", "live", "user_live"]),
      test_live: Path.join(["test", "#{app}_web", "live", "user_live"])
    }
  end

  # Wire a production email provider (Resend) without touching dev/test, which
  # keep Swoosh's local mailbox. Shared by every flow that delivers login email
  # (magic_link sends the link; otp/password_otp send the code). Idempotent.
  defp mailer_steps(ctx) do
    [
      file_patch("config/runtime.exs", &AP.mailer_resend_runtime(&1, ctx.app_atom, ctx.app_mod)),
      write_env_example(".env.example")
    ]
  end

  # Dev-only automatic DB setup: patch application.ex to create + migrate the DB
  # on boot, gated on a flag that only config/dev.exs sets — a guaranteed no-op
  # in test/prod. A context-level concern, so every flow (otp, password_otp,
  # magic_link, custom) gets it. Idempotent.
  defp dev_setup_steps(ctx) do
    [
      file_patch(
        Path.join(ctx.acc, "application.ex"),
        &AP.application_auto_setup(&1, ctx.app_atom, ctx.repo)
      ),
      file_patch("config/dev.exs", &AP.config_dev_auto_setup(&1, ctx.app_atom))
    ]
  end

  defp gen_otp(opts, variant) do
    digits = opts[:otp_length] || 6
    expiry = opts[:otp_expiry] || 10
    ctx = app_context()

    context = [
      file_patch(
        Path.join(ctx.acc, "accounts/user_token.ex"),
        &AP.user_token_otp(&1, digits, expiry)
      ),
      file_patch(Path.join(ctx.acc, "accounts.ex"), &AP.accounts_otp/1),
      file_patch(Path.join(ctx.acc, "accounts/user_notifier.ex"), &AP.notifier_otp(&1, expiry))
    ]

    web_layer = web_layer(variant, ctx)

    print_results(context ++ web_layer ++ mailer_steps(ctx) ++ dev_setup_steps(ctx))

    done("""
    #{variant_label(variant)} wired (#{digits}-digit, #{expiry}-min) — request a code → verify it,
    single-use + constant-time + session-fixation guard + Hammer rate limiting
    (request + verify, tunable in config/config.exs). Codes deliver via Swoosh's
    local mailbox in dev; prod uses Resend (set RESEND_API_KEY — see .env.example).

    Next: mix deps.get && mix ecto.migrate, then visit /users/log-in.
    """)
  end

  defp variant_label(:otp), do: "OTP login"
  defp variant_label(:password_otp), do: "Password + OTP login"

  # The OTP request/verify routes, the verify screen, the secure session-controller
  # clause, the Hammer rate limiter, and the obsolete-magic-link-route cleanup are
  # identical for both variants — only the login screen and the stale-test handling
  # differ. The shared rate-limiter pieces are extracted into rate_limit_steps/1.
  defp web_layer(:otp, ctx) do
    [
      write_file(
        Path.join(ctx.live, "login.ex"),
        AP.login_otp_view(ctx.web, ctx.app_mod, ctx.app_atom),
        :overwrite
      )
      | otp_verify_steps(ctx) ++
          rate_limit_steps(ctx) ++ otp_stale_tests(ctx) ++ submit_button_steps(ctx)
    ]
  end

  defp web_layer(:password_otp, ctx) do
    [
      write_file(
        Path.join(ctx.live, "login.ex"),
        AP.login_password_otp_view(ctx.web, ctx.app_mod, ctx.app_atom),
        :overwrite
      )
      | otp_verify_steps(ctx) ++
          rate_limit_steps(ctx) ++ password_otp_stale_tests(ctx) ++ submit_button_steps(ctx)
    ]
  end

  # Skua's <.button> defaults to type="button" (never submits a form by
  # accident); the auth views rely on the button submitting. Add type="submit"
  # to the typeless <.button>s in every generated auth LiveView. Idempotent.
  defp submit_button_steps(ctx) do
    ctx.live
    |> Path.join("*.ex")
    |> Path.wildcard()
    |> Enum.map(fn path -> file_patch(path, &AP.submit_buttons/1) end)
  end

  # Verify route + screen + secure controller clause + registration → verify.
  # Shared by both variants (registration stays email-only; the magic-link route
  # it used to link to is removed by router_otp, so it now issues an OTP instead).
  defp otp_verify_steps(ctx) do
    [
      write_file(
        Path.join(ctx.live, "otp_verify.ex"),
        AP.otp_verify_view(ctx.web, ctx.app_mod, ctx.app_atom),
        :create
      ),
      file_patch(Path.join(ctx.live, "registration.ex"), &AP.registration_otp/1),
      file_patch(Path.join(ctx.web_dir, "router.ex"), &AP.router_otp/1),
      file_patch(
        Path.join([ctx.web_dir, "controllers", "user_session_controller.ex"]),
        &AP.session_controller_otp/1
      ),
      rm_file(Path.join(ctx.live, "confirmation.ex"))
    ]
  end

  # Hammer rate limiter: module + dep + supervision + config. Both variants need
  # it — the verify-side limit is the brute-force gate on the OTP code space.
  defp rate_limit_steps(ctx) do
    [
      write_file(
        Path.join(ctx.acc, "accounts/rate_limit.ex"),
        AP.rate_limit_view(ctx.app_atom, ctx.accounts_mod),
        :create
      ),
      file_patch("mix.exs", &AP.mix_exs_hammer/1),
      file_patch(
        Path.join(ctx.acc, "application.ex"),
        &AP.application_supervision(&1, ctx.accounts_mod)
      ),
      file_patch("config/config.exs", &AP.config_rate_limit(&1, ctx.app_atom, ctx.accounts_mod))
    ]
  end

  # otp: registration is passwordless and confirmation is gone, so all three
  # stock magic-link LiveView tests are obsolete; replace with a context OTP test.
  defp otp_stale_tests(ctx) do
    [
      rm_file(Path.join(ctx.test_live, "confirmation_test.exs")),
      rm_file(Path.join(ctx.test_live, "login_test.exs")),
      rm_file(Path.join(ctx.test_live, "registration_test.exs")),
      write_file(
        Path.join(["test", ctx.app, "accounts_otp_test.exs"]),
        AP.otp_test_view(ctx.app_mod),
        :create
      )
    ]
  end

  # password_otp: the confirmation screen + its test are gone and the login screen
  # is rewritten (no magic-link form), so swap the stock login test for one that
  # covers BOTH the kept password path and the new OTP-request path. The stock
  # registration test stays valid (it now redirects to the verify screen) and the
  # password/session/accounts tests are untouched, so we keep them. We still add
  # the context OTP test to lock down the secure single-use path.
  defp password_otp_stale_tests(ctx) do
    [
      rm_file(Path.join(ctx.test_live, "confirmation_test.exs")),
      rm_file(Path.join(ctx.test_live, "registration_test.exs")),
      write_file(
        Path.join(ctx.test_live, "login_test.exs"),
        AP.login_password_otp_test_view(ctx.web, ctx.app_mod),
        :overwrite
      ),
      write_file(
        Path.join(["test", ctx.app, "accounts_otp_test.exs"]),
        AP.otp_test_view(ctx.app_mod),
        :create
      )
    ]
  end

  # --- helpers --------------------------------------------------------------

  defp file_patch(path, transform) do
    cond do
      not File.exists?(path) ->
        {:manual, path, "not found — apply this step manually"}

      true ->
        case transform.(File.read!(path)) do
          :skip -> {:skip, path}
          {:ok, new} -> File.write!(path, new) && {:ok, path}
          {:manual, msg} -> {:manual, path, msg}
        end
    end
  end

  # mode :create skips if the file exists; :overwrite always writes.
  defp write_file(path, contents, mode) do
    if mode == :create and File.exists?(path) do
      {:skip, path}
    else
      File.mkdir_p!(Path.dirname(path))
      File.write!(path, contents)
      {:ok, path}
    end
  end

  defp rm_file(path) do
    if File.exists?(path) do
      File.rm!(path)
      {:ok, path <> " (removed)"}
    else
      {:skip, path}
    end
  end

  # Idempotent + non-destructive .env.example: create it if absent, otherwise
  # append the RESEND_API_KEY line only if it isn't already present (preserving
  # any keys the app already documents).
  defp write_env_example(path) do
    cond do
      not File.exists?(path) ->
        File.write!(path, AP.env_example_view())
        {:ok, path}

      String.contains?(File.read!(path), "RESEND_API_KEY") ->
        {:skip, path}

      true ->
        File.write!(path, ensure_trailing_newline(File.read!(path)) <> AP.env_example_view())
        {:ok, path}
    end
  end

  defp ensure_trailing_newline(content) do
    if content == "" or String.ends_with?(content, "\n"), do: content, else: content <> "\n"
  end

  defp print_results(results) do
    Enum.each(results, fn
      {:ok, path} -> Mix.shell().info([:green, "  ✓ ", :reset, "patched — ", path])
      {:skip, path} -> Mix.shell().info([:faint, "  • already done — ", path, :reset])
      {:manual, path, msg} -> Mix.shell().info([:yellow, "  ! ", :reset, path, ": ", msg])
    end)
  end

  defp done(msg), do: Mix.shell().info([:green, "\n  ✓ ", :reset, String.trim(msg), "\n"])
end