Powered by AppSignal & Oban Pro

Trilha Phoenix · Escopos

14_escopos.livemd

Trilha Phoenix · Escopos

Mix.install(
  [
    {:phoenix, "~> 1.8.0"},
    {:phoenix_live_view, "~> 1.1"},
    {:phoenix_ecto, "~> 4.6"},
    {:ecto_sql, "~> 3.13"},
    {:postgrex, ">= 0.0.0"},
    {:bandit, "~> 1.5"},
    {:jason, "~> 1.4"},
    {:kino, "~> 0.14"},
    {:swoosh, "~> 1.16"},
    {:pbkdf2_elixir, "~> 2.0"}
  ],
  consolidate_protocols: false,
  config: [
    phoenix: [json_library: Jason, plug_init_mode: :runtime],
    swoosh: [api_client: false],
    hello: [
      {HelloWeb.Endpoint,
       [
         adapter: Bandit.PhoenixAdapter,
         http: [ip: {127, 0, 0, 1}, port: 4010],
         server: true,
         debug_errors: true,
         secret_key_base: String.duplicate("trilha-phoenix-", 5),
         render_errors: [formats: [html: HelloWeb.ErrorHTML, json: HelloWeb.ErrorJSON], layout: false],
         pubsub_server: Hello.PubSub
       ]},
      {Hello.Mailer, [adapter: Swoosh.Adapters.Local]}
    ]
  ]
)

defmodule Trilha do
  @moduledoc """
  Apoio deste notebook: verifica a conexão com o PostgreSQL, mostra tabelas como
  o psql mostraria, registra migrations, faz requisições ao endpoint como um
  navegador faria, confere os exercícios e guarda as perguntas e as soluções.

  Não faz parte do Phoenix nem do Ecto. Você não precisa ler este módulo para seguir o guia.
  """

  @endpoint HelloWeb.Endpoint
  @router HelloWeb.Router
  @repo Hello.Repo

  # --- banco de dados ------------------------------------------------------------

  @doc "Tenta uma conexão com o PostgreSQL usando a configuração do Hello.Repo."
  def verificar_postgres(extra \\ []) do
    config = Keyword.merge(apply(@repo, :config, []), extra)
    onde = "`#{config[:username]}@#{config[:hostname]}:#{config[:port] || 5432}`"

    # Conecta ao banco "postgres", que sempre existe: o banco da trilha talvez ainda não tenha sido criado.
    resultado =
      try do
        Postgrex.Protocol.connect(Postgrex.Utils.default_opts(Keyword.put(config, :database, "postgres")))
      rescue
        erro -> {:error, erro}
      end

    case resultado do
      {:ok, estado} ->
        Postgrex.Protocol.disconnect(%RuntimeError{message: "fim da verificação"}, estado)
        Kino.Markdown.new("**Conectou** ao PostgreSQL em #{onde}. Pode seguir.")

      {:error, erro} ->
        Kino.Markdown.new(
          "**Não conectou** ao PostgreSQL em #{onde}. A mensagem do Postgrex:\n\n" <>
            cerca(Exception.message(erro), "") <> "\n\n" <> dica_conexao(erro)
        )
    end
  end

  defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28P01"}}) do
    "**Senha recusada.** Confira `username` e `password` na célula `config/dev.exs`. " <>
      "Com autenticação por senha, o PostgreSQL dá esta mesma resposta quando o usuário não existe."
  end

  defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28000", message: mensagem}}) do
    cond do
      mensagem =~ ~r/ident|peer/i ->
        "**O servidor não aceita senha para esta conexão.** Ele está configurado para autenticação `ident` ou `peer`, " <>
          "o padrão de algumas distribuições Linux, como o Fedora. O `LEIA-ME.md` da trilha mostra como trocar para senha no `pg_hba.conf`."

      mensagem =~ "does not exist" ->
        "**O usuário não existe no PostgreSQL.** Crie-o no `psql` ou troque `username` na célula `config/dev.exs`."

      true ->
        "**O servidor recusou a conexão.** Leia a mensagem acima e confira o `pg_hba.conf` do servidor."
    end
  end

  defp dica_conexao(%DBConnection.ConnectionError{message: mensagem}) do
    cond do
      mensagem =~ "econnrefused" ->
        "**Nada responde nesse endereço.** O servidor PostgreSQL está parado, ou escuta em outra porta. O `LEIA-ME.md` mostra como iniciá-lo."

      mensagem =~ "nxdomain" ->
        "**O nome do host não existe.** Confira `hostname` na célula `config/dev.exs`."

      true ->
        "**A conexão falhou antes de chegar ao PostgreSQL.** Confira `hostname` e `port` na célula `config/dev.exs`."
    end
  end

  defp dica_conexao(_erro), do: "Confira a configuração na célula `config/dev.exs`."

  @doc """
  Confere se o servidor tem a extensão citext, que a migration do phx.gen.auth
  cria. Algumas distribuições Linux a separam num pacote à parte.
  """
  def verificar_citext do
    case sql("SELECT default_version FROM pg_available_extensions WHERE name = 'citext'").rows do
      [[versao]] ->
        Kino.Markdown.new("O servidor tem a extensão `citext` (versão #{versao}). Pode seguir.")

      [] ->
        Kino.Markdown.new(
          "**O servidor não tem a extensão `citext`.** A migration do `phx.gen.auth` vai falhar com " <>
            "`extension \"citext\" is not available`. No Fedora, ela vem no pacote `postgresql-contrib`:\n\n" <>
            cerca("sudo dnf install postgresql-contrib\nsudo systemctl restart postgresql", "") <>
            "\n\nO `LEIA-ME.md` da trilha tem os detalhes. Depois de instalar, avalie esta célula de novo."
        )
    end
  end

  @doc "Apaga todas as tabelas do banco da trilha, inclusive schema_migrations, e esquece os visitantes."
  def recomecar do
    sql("DROP SCHEMA public CASCADE")
    sql("CREATE SCHEMA public")
    esquecer_visitantes()

    Kino.Markdown.new(
      "O banco `#{apply(@repo, :config, [])[:database]}` está vazio: nenhuma tabela, nenhuma migration registrada."
    )
  end

  @doc "Registra uma migration com a versão que, no projeto, viria do nome do arquivo."
  def migracao(versao, modulo) do
    :persistent_term.put({__MODULE__, :migracoes}, Map.put(mapa_migracoes(), versao, modulo))
    nome = modulo |> Module.split() |> List.last() |> Macro.underscore()

    Kino.Markdown.new("No projeto, esta migration seria o arquivo `priv/repo/migrations/#{versao}_#{nome}.exs`.")
  end

  @doc "As migrations registradas, como pares {versão, módulo}, em ordem de versão."
  def migracoes, do: mapa_migracoes() |> Enum.sort()

  defp mapa_migracoes, do: :persistent_term.get({__MODULE__, :migracoes}, %{})

  @doc "Lista as tabelas e sequências do banco, como o comando \\d do psql."
  def relacoes do
    resultado =
      sql("""
      SELECT n.nspname AS "Schema", c.relname AS "Name",
             CASE c.relkind WHEN 'r' THEN 'table' WHEN 'S' THEN 'sequence' WHEN 'v' THEN 'view' END AS "Type",
             pg_get_userbyid(c.relowner) AS "Owner"
      FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
      WHERE n.nspname = 'public' AND c.relkind IN ('r', 'S', 'v')
      ORDER BY c.relname
      """)

    if resultado.rows == [] do
      Kino.Markdown.new("`\\d`: o banco não tem nenhuma tabela.")
    else
      Kino.Markdown.new("**`\\d`**\n\n" <> tabela_markdown(resultado))
    end
  end

  @doc "Mostra as colunas, os índices e as chaves estrangeiras de uma tabela, como o comando \\d TABELA do psql."
  def descrever(tabela) do
    colunas =
      sql(
        """
        SELECT a.attname AS "Column", format_type(a.atttypid, a.atttypmod) AS "Type",
               CASE WHEN a.attnotnull THEN 'not null' ELSE '' END AS "Nullable",
               coalesce(pg_get_expr(d.adbin, d.adrelid), '') AS "Default"
        FROM pg_attribute a
        LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
        WHERE a.attrelid = to_regclass($1) AND a.attnum > 0 AND NOT a.attisdropped
        ORDER BY a.attnum
        """,
        [tabela]
      )

    if colunas.rows == [] do
      Kino.Markdown.new("`\\d #{tabela}`: a tabela `#{tabela}` não existe.")
    else
      indices = sql("SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = $1 ORDER BY indexname", [tabela])
      lista = Enum.map_join(indices.rows, "\n", fn [definicao] -> "* `#{definicao}`" end)

      chaves =
        sql(
          """
          SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
          WHERE conrelid = to_regclass($1) AND contype = 'f' ORDER BY conname
          """,
          [tabela]
        )

      fks =
        if chaves.rows == [],
          do: "",
          else: "\n\n**Chaves estrangeiras**\n\n" <> Enum.map_join(chaves.rows, "\n", fn [nome, definicao] -> "* `#{nome}` #{definicao}" end)

      Kino.Markdown.new(
        "**`\\d #{tabela}`**\n\n" <>
          tabela_markdown(colunas) <> "\n\n**Índices**\n\n" <> if(lista == "", do: "nenhum", else: lista) <> fks
      )
    end
  end

  @doc "Executa um SQL e mostra o resultado como tabela."
  def consultar(consulta, parametros \\ []) do
    resultado = sql(consulta, parametros)
    Kino.Markdown.new(cerca(consulta, "sql") <> "\n\n" <> tabela_markdown(resultado))
  end

  defp sql(consulta, parametros \\ []) do
    apply(@repo, :query!, [consulta, parametros, [log: false]])
  end

  defp tabela_markdown(%{columns: colunas, rows: linhas}) do
    cabecalho = "| " <> Enum.join(colunas, " | ") <> " |"
    separador = "| " <> Enum.map_join(colunas, " | ", fn _ -> "---" end) <> " |"

    corpo =
      Enum.map(linhas, fn linha ->
        "| " <> Enum.map_join(linha, " | ", &celula/1) <> " |"
      end)

    Enum.join([cabecalho, separador | corpo], "\n")
  end

  defp celula(nil), do: ""
  defp celula(""), do: ""
  defp celula(valor) when is_binary(valor), do: "`" <> String.replace(valor, "|", "\\|") <> "`"
  defp celula(%Decimal{} = valor), do: "`" <> Decimal.to_string(valor) <> "`"
  defp celula(valor), do: "`" <> (valor |> inspect() |> String.replace("|", "\\|")) <> "`"

  # --- requisições -----------------------------------------------------------

  @doc "Despacha uma requisição para o endpoint, sem passar pela rede."
  def requisicao(metodo \\ :get, caminho, parametros \\ nil, conn \\ Phoenix.ConnTest.build_conn()) do
    descartar_respostas_antigas()

    conn = Phoenix.ConnTest.dispatch(conn, @endpoint, metodo, caminho, parametros)

    # Para NoRouteError (e só para ela), o Phoenix envia a página de erro e
    # devolve o conn sem relançar a exceção. Reconstruímos a exceção aqui.
    if conn.state == :sent and conn.status == nil do
      {:erro, Phoenix.Router.NoRouteError.exception(conn: conn, router: @router)}
    else
      {:ok, conn}
    end
  rescue
    erro -> {:erro, desembrulhar(erro)}
  end

  # Cada resposta enviada pelo adaptador de testes deixa duas mensagens no
  # processo que fez a requisição. Se ficarem acumuladas, o Phoenix acha que a
  # próxima página de erro já foi enviada e não a renderiza.
  defp descartar_respostas_antigas do
    receive do
      {ref, {status, cabecalhos, _corpo}} when is_reference(ref) and is_integer(status) and is_list(cabecalhos) ->
        descartar_respostas_antigas()

      {:plug_conn, :sent} ->
        descartar_respostas_antigas()
    after
      0 -> :ok
    end
  end

  defp desembrulhar(%Plug.Conn.WrapperError{reason: motivo}), do: desembrulhar(motivo)
  defp desembrulhar(erro), do: erro

  @doc "Faz a requisição e mostra o status, o content-type e o corpo da resposta."
  def mostrar(metodo \\ :get, caminho, parametros \\ nil) do
    titulo = "### #{metodo |> to_string() |> String.upcase()} #{caminho}"

    corpo =
      case requisicao(metodo, caminho, parametros) do
        {:ok, conn} ->
          tipo = conn |> Plug.Conn.get_resp_header("content-type") |> Enum.join()

          "**Status** `#{conn.status}` · **content-type** `#{tipo}`\n\n" <>
            cerca(to_string(conn.resp_body), if(tipo =~ "json", do: "json", else: "html"))

        {:erro, erro} ->
          "**A requisição levantou uma exceção:**\n\n" <> cerca(Exception.format_banner(:error, erro), "")
      end

    Kino.Markdown.new(titulo <> "\n\n" <> corpo)
  end

  # --- navegador ---------------------------------------------------------------

  @doc """
  Faz uma requisição como um navegador: envia os cookies que o visitante `quem`
  recebeu antes (a sessão, onde ficam a flash e o login) e segue os redirects.
  `quem` é um nome qualquer, como "ana"; cada nome tem os seus próprios cookies.
  Devolve `{:ok, conn}` com a última resposta, ou `{:erro, exceção}`.
  """
  def visitar(quem, metodo, caminho, parametros \\ nil) do
    quem |> seguir(metodo, caminho, parametros, []) |> List.last() |> elem(1)
  end

  @doc "Como `visitar/4`, mas mostra cada passo, a flash e o texto da página final."
  def navegar(quem, metodo \\ :get, caminho, parametros \\ nil) do
    passos = seguir(quem, metodo, caminho, parametros, [])

    linhas =
      passos
      |> Enum.with_index(1)
      |> Enum.map(fn {{requisicao, resultado}, i} -> linha_passo(i, requisicao, resultado) end)

    cabecalho = [
      "### #{quem}: #{rotulo(metodo, caminho)}",
      "",
      "| # | Requisição | Status | Redireciona para |",
      "| --- | --- | --- | --- |"
    ]

    Kino.Markdown.new(Enum.join(cabecalho ++ linhas, "\n") <> "\n\n" <> resumo_final(List.last(passos)))
  end

  @doc "Esquece os cookies de todos os visitantes, como se cada um abrisse um navegador novo."
  def esquecer_visitantes do
    for {{__MODULE__, :cookies, _} = chave, _} <- :persistent_term.get(), do: :persistent_term.erase(chave)
    :ok
  end

  defp seguir(quem, metodo, caminho, parametros, feitos) do
    conn = Enum.reduce(cookies(quem), Phoenix.ConnTest.build_conn(), fn {nome, valor}, c -> Plug.Test.put_req_cookie(c, nome, valor) end)
    resultado = requisicao(metodo, caminho, parametros, conn)
    feitos = feitos ++ [{rotulo(metodo, caminho), resultado}]

    with {:ok, resp} <- resultado do
      guardar_cookies(quem, resp)
    end

    with {:ok, resp} <- resultado,
         true <- resp.status in 300..399 and length(feitos) < 6,
         [destino] <- Plug.Conn.get_resp_header(resp, "location"),
         true <- String.starts_with?(destino, "/") do
      seguir(quem, :get, destino, nil, feitos)
    else
      _ -> feitos
    end
  end

  defp cookies(quem), do: :persistent_term.get({__MODULE__, :cookies, quem}, %{})

  defp guardar_cookies(quem, resp) do
    novos =
      Enum.reduce(resp.resp_cookies, cookies(quem), fn {nome, opcoes}, acc ->
        if opcoes[:max_age] == 0, do: Map.delete(acc, nome), else: Map.put(acc, nome, opcoes[:value])
      end)

    :persistent_term.put({__MODULE__, :cookies, quem}, novos)
  end

  defp rotulo(metodo, caminho), do: "#{metodo |> to_string() |> String.upcase()} #{caminho}"

  defp linha_passo(i, requisicao, {:ok, conn}) do
    destino = conn |> Plug.Conn.get_resp_header("location") |> List.first("")
    "| #{i} | `#{requisicao}` | `#{conn.status}` | #{if destino == "", do: "", else: "`#{destino}`"} |"
  end

  defp linha_passo(i, requisicao, {:erro, erro}) do
    "| #{i} | `#{requisicao}` | exceção: `#{erro.__struct__ |> inspect()}` | |"
  end

  defp resumo_final({_requisicao, {:ok, conn}}) do
    flash =
      case conn.assigns[:flash] do
        mapa when is_map(mapa) and map_size(mapa) > 0 ->
          "**Flash:** " <> Enum.map_join(mapa, " · ", fn {tipo, msg} -> "#{tipo}: \"#{msg}\"" end) <> "\n\n"

        _ ->
          ""
      end

    flash <> "**Texto da página** (sem as tags):\n\n" <> cerca(texto(to_string(conn.resp_body)), "")
  end

  defp resumo_final({_requisicao, {:erro, erro}}) do
    "**A requisição levantou uma exceção:**\n\n" <> cerca(Exception.format_banner(:error, erro), "") <>
      "\n\nCom `debug_errors: true`, o notebook mostra a exceção. Em produção, o Phoenix responderia com o status `#{Plug.Exception.status(erro)}`."
  end

  @doc "O texto de uma página HTML, sem as tags: o que se lê no navegador."
  def texto(html) do
    conteudo =
      case Regex.run(~r{<main[^>]*>(.*)</main>}s, html) do
        [_, dentro] -> dentro
        nil -> html
      end

    conteudo
    |> String.replace(~r{<(script|style|head)[^>]*>.*?</\1>}s, "")
    |> String.replace(~r{<input[^>]*type="hidden"[^>]*>}, "")
    |> String.replace(~r{<option[^>]*\sselected[^>]*>}, "[x] ")
    |> String.replace(~r{<option[^>]*>}, "[ ] ")
    |> then(&Regex.replace(~r{<input[^>]*\svalue="([^"]*)"[^>]*>}, &1, fn _, valor -> if valor == "", do: " ", else: " [" <> valor <> "] " end))
    |> String.replace(~r{</?(p|div|li|tr|h\d|header|footer|ul|table|thead|tbody|form|label|select|option|section|br)[^>]*>}, "\n")
    |> String.replace(~r{</t[dh]>}, "  ")
    |> String.replace(~r{<[^>]+>}, "")
    |> String.replace(["&amp;", "<", ">", """, "'", "&nbsp;"], fn
      "&amp;" -> "&"
      "<" -> "<"
      ">" -> ">"
      """ -> "\""
      "'" -> "'"
      "&nbsp;" -> " "
    end)
    |> String.split("\n")
    |> Enum.map(&(&1 |> String.replace(~r/[ \t]+/, " ") |> String.trim()))
    |> Enum.reject(&(&1 == ""))
    |> Enum.join("\n")
  end

  defp cerca(texto, linguagem) do
    marca = String.duplicate("`", 3)
    marca <> linguagem <> "\n" <> String.trim(texto) <> "\n" <> marca
  end

  # --- e-mails ---------------------------------------------------------------------

  @doc """
  Os e-mails enviados pela aplicação, do mais novo ao mais antigo. Em
  desenvolvimento, o Swoosh guarda cada e-mail na memória em vez de enviá-lo;
  é a mesma caixa que aparece em http://localhost:4010/dev/mailbox.
  """
  def caixa_de_entrada do
    emails = Swoosh.Adapters.Local.Storage.Memory.all()

    if emails == [] do
      Kino.Markdown.new("A caixa de entrada está vazia.")
    else
      linhas =
        Enum.map(emails, fn email ->
          para = email.to |> Enum.map_join(", ", fn {_nome, endereco} -> endereco end)
          "| `#{para}` | #{email.subject} |"
        end)

      Kino.Markdown.new(Enum.join(["| Para | Assunto |", "| --- | --- |" | linhas], "\n"))
    end
  end

  @doc "O último e-mail enviado para `endereco`, com o texto inteiro."
  def ultimo_email(endereco) do
    case email_para(endereco) do
      nil -> Kino.Markdown.new("Nenhum e-mail para `#{endereco}`.")
      email -> Kino.Markdown.new("**#{email.subject}**\n\n" <> cerca(email.text_body, ""))
    end
  end

  @doc "O caminho do link (o que vem depois de http://localhost:4010) no último e-mail para `endereco`."
  def link_do_email(endereco) do
    with %{text_body: corpo} <- email_para(endereco),
         [url | _] <- Regex.run(~r{https?://\S+}, corpo) do
      %URI{path: caminho} = URI.parse(url)
      caminho
    else
      _ -> raise "nenhum e-mail com link para #{endereco}"
    end
  end

  defp email_para(endereco) do
    Swoosh.Adapters.Local.Storage.Memory.all()
    |> Enum.find(fn email -> Enum.any?(email.to, fn {_nome, para} -> para == endereco end) end)
  end

  # --- usuários e escopos -------------------------------------------------------------

  @senha "senha da trilha"

  @doc "Faz o visitante `quem` entrar com o e-mail e a senha da trilha, como no formulário de login."
  def entrar(quem, email) do
    esquecer(quem)
    navegar(quem, :post, "/users/log-in", %{"user" => %{"email" => email, "password" => @senha}})
  end

  @doc "Mostra o que o pipeline :browser deixou em conn.assigns para o visitante `quem`."
  def escopos(quem, caminho \\ "/") do
    case visitar(quem, :get, caminho) do
      {:ok, conn} ->
        linhas =
          for chave <- [:current_scope, :session_scope], Map.has_key?(conn.assigns, chave) do
            "* `conn.assigns.#{chave}`:\n\n" <> cerca(inspect(Map.get(conn.assigns, chave), pretty: true, limit: 12), "elixir")
          end

        Kino.Markdown.new("### #{quem}: GET #{caminho}\n\n" <> Enum.join(linhas, "\n\n"))

      {:erro, erro} ->
        Kino.Markdown.new("A requisição levantou `#{inspect(erro.__struct__)}`.")
    end
  end

  @doc "Esquece os cookies de um visitante só."
  def esquecer(quem) do
    :persistent_term.erase({__MODULE__, :cookies, quem})
    :ok
  end

  defp logado(quem, email) do
    esquecer(quem)

    case visitar(quem, :post, "/users/log-in", %{"user" => %{"email" => email, "password" => @senha}}) do
      {:ok, %{assigns: %{current_scope: %{user: %{email: ^email}}}}} -> :ok
      outro -> raise "não consegui entrar como #{email}: #{inspect(outro, limit: 5)}"
    end
  end

  # Um conn com os cookies do visitante, para uma requisição sem seguir redirects.
  defp conn_de(quem) do
    Enum.reduce(cookies(quem), Phoenix.ConnTest.build_conn(), fn {nome, valor}, c -> Plug.Test.put_req_cookie(c, nome, valor) end)
  end

  defp id_do_usuario(email), do: hd(coluna("SELECT id FROM users WHERE email = $1", [email]))

  defp escopo_de(email), do: apply(Hello.Accounts.Scope, :for_user, [apply(Hello.Accounts, :get_user_by_email, [email])])

  defp corpo(conn), do: to_string(conn.resp_body)

  # Cria uma organização (ou loja) com um membro, direto no banco, para as conferências.
  defp org_de_teste(slug, email) do
    [[id]] =
      sql("INSERT INTO organizations (name, slug, inserted_at, updated_at) VALUES ($1, $1, now(), now()) RETURNING id", [slug]).rows

    sql("INSERT INTO organizations_users (organization_id, user_id) VALUES ($1, $2)", [id, id_do_usuario(email)])
    id
  end

  defp loja_de_teste(slug, email) do
    [[id]] = sql("INSERT INTO lojas (nome, slug, inserted_at, updated_at) VALUES ($1, $1, now(), now()) RETURNING id", [slug]).rows
    sql("INSERT INTO lojas_usuarios (loja_id, user_id) VALUES ($1, $2)", [id, id_do_usuario(email)])
    id
  end

  defp limpando(teste) do
    teste.()
  after
    tabelas = coluna("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'")
    if "posts" in tabelas, do: sql("DELETE FROM posts WHERE title LIKE 'Conferência%'")
    if "notes" in tabelas, do: sql("DELETE FROM notes WHERE title LIKE 'Conferência%'")
    if "organizations" in tabelas, do: sql("DELETE FROM organizations WHERE slug LIKE 'conferencia-%'")
    if "lojas" in tabelas, do: sql("DELETE FROM lojas WHERE slug LIKE 'conferencia-%'")

    # As linhas da conferência gastaram números da sequência do id. Voltar a
    # sequência para logo depois do maior id mantém os ids do texto (o post 1, a nota 1).
    for tabela <- ~w(posts notes organizations articles lojas produtos), tabela in tabelas do
      sql("SELECT setval(pg_get_serial_sequence($1, 'id'), coalesce((SELECT max(id) FROM #{tabela}), 0) + 1, false)", [tabela])
    end
  end

  defp levanta(funcao) do
    funcao.()
    :nao_levantou
  rescue
    erro -> erro
  end

  # --- perguntas -------------------------------------------------------------

  @doc "Uma pergunta de múltipla escolha, respondida antes de ver a explicação."
  def pergunta(id) do
    %{texto: texto, opcoes: opcoes, correta: correta, explicacao: explicacao} = Map.fetch!(perguntas(), id)

    quadro = Kino.Frame.new()

    letras = Enum.map(opcoes, fn {letra, _texto} -> {letra, to_string(letra)} end)
    lista = Enum.map_join(opcoes, "\n", fn {letra, opcao} -> "* **#{letra})** #{opcao}" end)

    formulario =
      Kino.Control.form(
        [resposta: Kino.Input.select("Sua resposta", [{nil, "Escolha"} | letras])],
        submit: "Conferir"
      )

    Kino.listen(formulario, fn %{data: %{resposta: resposta}} ->
      retorno =
        cond do
          resposta == nil -> "Escolha uma opção antes de conferir."
          resposta == correta -> "**Certo.** " <> explicacao
          true -> "**Ainda não.** Releia o trecho acima e tente outra opção."
        end

      Kino.Frame.render(quadro, Kino.Markdown.new(retorno))
    end)

    Kino.Layout.grid([Kino.Markdown.new("**Antes de continuar:** " <> texto <> "\n\n" <> lista), formulario, quadro])
  end

  defp perguntas do
    %{
      owasp: %{
        texto:
          "a aplicação guarda posts de vários usuários na mesma tabela. Onde fica a proteção que impede a Bia de ler um post da Ana?",
        opcoes: [
          a: "No router: basta pôr as rotas atrás de `require_authenticated_user`.",
          b: "No template: a página esconde os posts que não são de quem está logado.",
          c: "Em cada operação do contexto: toda consulta filtra pelo usuário do escopo, e toda alteração confere o dono.",
          d: "No banco: o PostgreSQL só devolve as linhas do usuário conectado."
        ],
        correta: :c,
        explicacao:
          "O router só garante que existe *alguém* logado; não diz o que essa pessoa pode ver. É nas funções do contexto que a consulta precisa de `where: user_id == ^scope.user.id`. Esquecer esse filtro é o \"broken access control\" da lista da OWASP: a página funciona, e qualquer usuário logado lê os dados de qualquer outro trocando o `id` na URL."
      },
      anonimo: %{
        texto: "a Carla abriu a página inicial sem fazer login. O que há em `conn.assigns.current_scope`?",
        opcoes: [
          a: "Um `%Scope{user: nil}`.",
          b: "`nil`.",
          c: "Nada: a chave `:current_scope` não existe nos assigns.",
          d: "Um `%Scope{}` com um usuário visitante criado na hora."
        ],
        correta: :b,
        explicacao:
          "`fetch_current_scope_for_user` sempre atribui `:current_scope`, mesmo sem usuário: chama `Scope.for_user(nil)`, que devolve `nil`. Por isso os templates podem escrever `<%= if @current_scope do %>` sem medo de `KeyError`, e as funções do contexto, que casam com `%Scope{}`, falham de propósito se alguém as chamar sem escopo."
      },
      post_alheio: %{
        texto: "o post 1 é da Ana. A Bia, logada, pede `GET /posts/1`. O que acontece?",
        opcoes: [
          a: "A página mostra o post, porque a Bia está logada.",
          b: "A Bia é mandada para `/users/log-in`.",
          c: "`Blog.get_post!/2` não encontra o post: levanta `Ecto.NoResultsError`, que em produção vira uma resposta 404.",
          d: "O post aparece, mas sem o título e o corpo."
        ],
        correta: :c,
        explicacao:
          "A consulta é `Repo.get_by!(Post, id: id, user_id: scope.user.id)`. Para a Bia, não existe post com id 1 *e* `user_id` dela, então o resultado é o mesmo de um id inexistente. Responder 404, e não 403, também evita confirmar que o post existe."
      },
      dono: %{
        texto:
          "alguém chama `Blog.update_post(escopo_da_bia, post_da_ana, %{title: \"x\"})` direto, sem passar pelo controller. O que acontece?",
        opcoes: [
          a: "O post é atualizado, porque a função recebeu o post pronto.",
          b: "O post é atualizado e passa a ser da Bia.",
          c: "A função devolve `{:error, changeset}` com um erro de permissão.",
          d: "A função levanta `MatchError`: a primeira linha é `true = post.user_id == scope.user.id`."
        ],
        correta: :d,
        explicacao:
          "As funções geradas que recebem um registro já carregado conferem o dono antes de qualquer coisa. `true = ...` é uma afirmação: se o post não é do usuário do escopo, o casamento falha e o processo cai. Não é um erro para mostrar ao usuário; é um defeito no código que chamou a função, e cair alto é melhor do que gravar em silêncio."
      },
      pubsub: %{
        texto:
          "a célula assina as notificações de posts com o escopo da Ana. Em seguida, a Bia cria um post e a Ana cria outro. Quantas mensagens chegam?",
        opcoes: [
          a: "Duas: uma para cada post criado.",
          b: "Uma: a do post da Ana. O tópico tem o id do usuário (`\"user:1:posts\"`).",
          c: "Nenhuma: só LiveViews recebem essas mensagens.",
          d: "Duas, mas a do post da Bia vem sem o conteúdo."
        ],
        correta: :b,
        explicacao:
          "`subscribe_posts/1` e `broadcast_post/2` montam o nome do tópico com `scope.user.id`. Cada usuário escuta só o seu tópico, então o PubSub também respeita o escopo. Qualquer processo pode assinar, não só LiveViews; o LiveView gerado por `phx.gen.live` usa isso para atualizar a lista sozinho."
      },
      router_scope: %{
        texto: "o router tem `scope \"/\", HelloWeb do ... end`, e a conexão tem `@current_scope`. Qual a relação entre os dois?",
        opcoes: [
          a: "São a mesma coisa: o `scope` do router cria o `current_scope`.",
          b: "O `scope` do router agrupa rotas por caminho e pipelines; o `current_scope` diz quem faz a requisição e filtra os dados. Atuam em camadas diferentes.",
          c: "O `current_scope` só existe dentro de blocos `scope` do router.",
          d: "O `scope` do router define quais colunas o `current_scope` filtra."
        ],
        correta: :b,
        explicacao:
          "Os nomes se parecem e os propósitos também (restringir o acesso), mas um atua no roteamento e o outro na camada de dados. O `scope` do router decide quais plugs rodam para quais caminhos; é ali que `require_authenticated_user` garante que haja um usuário. O `current_scope` é um dado da requisição que o contexto usa em cada consulta."
      },
      assign_key: %{
        texto:
          "o guia configura o escopo de sessão com `assign_key: :current_scope`. Por que este notebook usa `:session_scope`?",
        opcoes: [
          a: "Porque `:current_scope` é uma palavra reservada do Phoenix.",
          b: "Porque o gerador não aceita `:current_scope` em escopos sem tabela.",
          c: "Porque `:current_scope` já guarda o escopo do usuário logado, e o root layout lê `@current_scope.user.email`. Dois escopos na mesma requisição precisam de chaves diferentes.",
          d: "Não há motivo; é só um nome diferente."
        ],
        correta: :c,
        explicacao:
          "O exemplo do guia imagina uma aplicação sem `phx.gen.auth`. Aqui a autenticação já põe um `Hello.Accounts.Scope` em `:current_scope`, e o root layout mostra `@current_scope.user.email`. Se o plug de sessão escrevesse um `%Hello.Scope{id: ...}` na mesma chave, o layout quebraria com `KeyError`. `assign_key` existe para isso: o gerador escreve `conn.assigns.session_scope` em todo o código que gera."
      },
      tabela_nil: %{
        texto: "na configuração do escopo de sessão, `schema_table: nil`. O que muda na migration gerada?",
        opcoes: [
          a: "Nada: `schema_table` só é usado nos testes.",
          b: "A coluna `session_id` é criada sem chave estrangeira, como um `bigint` comum, com o tipo de `schema_migration_type`.",
          c: "A tabela `notes` não é criada.",
          d: "A coluna `session_id` aponta para uma tabela `sessions` criada automaticamente."
        ],
        correta: :b,
        explicacao:
          "Com uma tabela, o gerador escreve `add :user_id, references(:users, on_delete: :delete_all)`. Sem tabela, não há o que referenciar: sai `add :session_id, :bigint`, com o tipo vindo de `schema_migration_type`. O índice em `session_id` continua, porque toda consulta filtra por ele."
      },
      org_no_browser: %{
        texto:
          "o guia põe `plug :assign_org_to_scope` no pipeline `:browser`, que roda antes de `require_authenticated_user`. O que acontece quando alguém sem login abre `/organizations/acme/articles`?",
        opcoes: [
          a: "É mandado para `/users/log-in`, como em qualquer rota autenticada.",
          b: "Vê os artigos da Acme, porque o plug achou a organização.",
          c: "Recebe um erro 500: o plug chama `get_organization_by_slug!(nil, \"acme\")` antes de `require_authenticated_user` agir, e nenhuma cláusula casa com `nil`.",
          d: "Recebe um 404, porque o slug não existe."
        ],
        correta: :c,
        explicacao:
          "Os plugs do pipeline `:browser` rodam primeiro, em ordem. Sem login, `current_scope` é `nil`, e a função do contexto, que casa com `%Scope{}`, levanta `FunctionClauseError`. A resposta é um 500, não o redirecionamento para o login. Por isso este notebook põe o plug no `pipe_through` do bloco das organizações, depois de `:require_authenticated_user`."
      }
    }
  end

  # --- soluções --------------------------------------------------------------

  @doc "Um botão que revela a solução de um exercício."
  def solucao(id) do
    quadro = Kino.Frame.new()
    botao = Kino.Control.button("Mostrar a solução")

    Kino.listen(botao, fn _evento ->
      Kino.Frame.render(quadro, Kino.Markdown.new(cerca(Map.fetch!(solucoes(), id), "elixir")))
    end)

    Kino.Layout.grid([botao, quadro])
  end

  defp solucoes do
    %{
      rotas_posts: ~S'''
      # Na célula do router: dentro do bloco que já passa por
      # :require_authenticated_user (o mesmo das configurações do usuário).
      scope "/", HelloWeb do
        pipe_through [:browser, :require_authenticated_user]

        get "/users/settings", UserSettingsController, :edit
        put "/users/settings", UserSettingsController, :update
        get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email

        resources "/posts", PostController
      end
      ''',
      assign_scope: ~S'''
      # Na célula do router, no lugar do assign_scope/2 provisório:
      defp assign_scope(conn, _opts) do
        if id = get_session(conn, :scope_id) do
          assign(conn, :session_scope, Hello.Scope.for_id(id))
        else
          id = System.unique_integer()

          conn
          |> put_session(:scope_id, id)
          |> assign(:session_scope, Hello.Scope.for_id(id))
        end
      end
      ''',
      org_por_slug: ~S'''
      # Na célula do Hello.Accounts, no lugar da versão sem filtro:
      def get_organization_by_slug!(%Scope{} = scope, slug) do
        Repo.one!(
          from o in Organization,
            join: u in assoc(o, :users),
            where: o.slug == ^slug and u.id == ^scope.user.id
        )
      end
      ''',
      scope_for: ~S'''
      defmodule Hello.Accounts.Scope do
        @moduledoc """
        Defines the scope of the caller to be used throughout the app.
        """

        alias Hello.Accounts
        alias Hello.Accounts.{User, Organization}

        defstruct user: nil, organization: nil

        def for_user(%User{} = user) do
          %__MODULE__{user: user}
        end

        def for_user(nil), do: nil

        def put_organization(%__MODULE__{} = scope, %Organization{} = organization) do
          %{scope | organization: organization}
        end

        def for(opts) when is_list(opts) do
          cond do
            opts[:user] && opts[:org] ->
              user = user(opts[:user])
              org = org(opts[:org])

              user
              |> for_user()
              |> put_organization(org)

            opts[:user] ->
              user = user(opts[:user])
              for_user(user)

            opts[:org] ->
              %__MODULE__{organization: org(opts[:org])}
          end
        end

        defp user(id) when is_integer(id) do
          Accounts.get_user!(id)
        end

        defp user(email) when is_binary(email) do
          Accounts.get_user_by_email(email)
        end

        defp org(id) when is_integer(id) do
          Accounts.get_organization!(id)
        end

        defp org(slug) when is_binary(slug) do
          Accounts.get_organization_by_slug!(slug)
        end
      end
      ''',
      brecho: ~S'''
      # priv/repo/migrations/20250613090000_create_lojas.exs
      defmodule Hello.Repo.Migrations.CreateLojas do
        use Ecto.Migration

        def change do
          create table(:lojas) do
            add :nome, :string, null: false
            add :slug, :string, null: false

            timestamps(type: :utc_datetime)
          end

          create unique_index(:lojas, [:slug])

          create table(:lojas_usuarios, primary_key: false) do
            add :loja_id, references(:lojas, on_delete: :delete_all), null: false
            add :user_id, references(:users, on_delete: :delete_all), null: false
          end

          create unique_index(:lojas_usuarios, [:loja_id, :user_id])
          create index(:lojas_usuarios, [:user_id])

          create table(:produtos) do
            add :nome, :string, null: false
            add :preco_centavos, :integer, null: false
            add :loja_id, references(:lojas, on_delete: :delete_all), null: false

            timestamps(type: :utc_datetime)
          end

          create index(:produtos, [:loja_id])
        end
      end

      Trilha.migracao(20250613090000, Hello.Repo.Migrations.CreateLojas)

      # Depois: Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)

      # lib/hello/lojas/loja.ex e lib/hello/lojas/produto.ex
      defmodule Hello.Lojas.Loja do
        use Ecto.Schema
        import Ecto.Changeset

        @derive {Phoenix.Param, key: :slug}
        schema "lojas" do
          field :nome, :string
          field :slug, :string

          many_to_many :usuarios, Hello.Accounts.User, join_through: "lojas_usuarios"

          timestamps(type: :utc_datetime)
        end

        @doc false
        def changeset(loja, attrs) do
          loja
          |> cast(attrs, [:nome, :slug])
          |> validate_required([:nome, :slug])
          |> validate_format(:slug, ~r/^[a-z0-9-]+$/)
          |> unique_constraint(:slug)
        end
      end

      defmodule Hello.Lojas.Produto do
        use Ecto.Schema
        import Ecto.Changeset

        schema "produtos" do
          field :nome, :string
          field :preco_centavos, :integer
          field :loja_id, :id

          timestamps(type: :utc_datetime)
        end

        @doc false
        def changeset(produto, attrs, loja_scope) do
          produto
          |> cast(attrs, [:nome, :preco_centavos])
          |> validate_required([:nome, :preco_centavos])
          |> validate_number(:preco_centavos, greater_than: 0)
          |> put_change(:loja_id, loja_scope.loja.id)
        end
      end

      # lib/hello/accounts/scope.ex: o campo loja e put_loja/2
      defmodule Hello.Accounts.Scope do
        @moduledoc """
        Defines the scope of the caller to be used throughout the app.
        """

        alias Hello.Accounts
        alias Hello.Accounts.{User, Organization}
        alias Hello.Lojas.Loja

        defstruct user: nil, organization: nil, loja: nil

        def for_user(%User{} = user) do
          %__MODULE__{user: user}
        end

        def for_user(nil), do: nil

        def put_organization(%__MODULE__{} = scope, %Organization{} = organization) do
          %{scope | organization: organization}
        end

        def put_loja(%__MODULE__{} = scope, %Loja{} = loja) do
          %{scope | loja: loja}
        end

        def for(opts) when is_list(opts) do
          cond do
            opts[:user] && opts[:org] ->
              user = user(opts[:user])
              org = org(opts[:org])

              user
              |> for_user()
              |> put_organization(org)

            opts[:user] ->
              user = user(opts[:user])
              for_user(user)

            opts[:org] ->
              %__MODULE__{organization: org(opts[:org])}
          end
        end

        defp user(id) when is_integer(id) do
          Accounts.get_user!(id)
        end

        defp user(email) when is_binary(email) do
          Accounts.get_user_by_email(email)
        end

        defp org(id) when is_integer(id) do
          Accounts.get_organization!(id)
        end

        defp org(slug) when is_binary(slug) do
          Accounts.get_organization_by_slug!(slug)
        end
      end

      # lib/hello/lojas.ex
      defmodule Hello.Lojas do
        @moduledoc """
        As lojas do Brechó e os produtos de cada loja.
        """

        import Ecto.Query, warn: false
        alias Hello.Repo

        alias Hello.Accounts.Scope
        alias Hello.Lojas.{Loja, Produto}

        def criar_loja(%Scope{} = scope, attrs) do
          %Loja{}
          |> Loja.changeset(attrs)
          |> Ecto.Changeset.put_assoc(:usuarios, [scope.user])
          |> Repo.insert()
        end

        @doc "A loja com o slug dado, só se o usuário do escopo for membro dela."
        def buscar_loja!(%Scope{} = scope, slug) do
          Repo.one!(
            from l in Loja,
              join: u in assoc(l, :usuarios),
              where: l.slug == ^slug and u.id == ^scope.user.id
          )
        end

        def listar_produtos(%Scope{} = scope) do
          Repo.all(from p in Produto, where: p.loja_id == ^scope.loja.id, order_by: p.nome)
        end

        def cadastrar_produto(%Scope{} = scope, attrs) do
          %Produto{}
          |> Produto.changeset(attrs, scope)
          |> Repo.insert()
        end
      end

      # No router: o plug e o bloco das lojas
      defp assign_loja_to_scope(conn, _opts) do
        current_scope = conn.assigns.current_scope
        loja = Hello.Lojas.buscar_loja!(current_scope, conn.params["loja"])
        assign(conn, :current_scope, Hello.Accounts.Scope.put_loja(current_scope, loja))
      end

      scope "/lojas/:loja", HelloWeb do
        pipe_through [:browser, :require_authenticated_user, :assign_loja_to_scope]

        get "/produtos", ProdutoController, :index
        post "/produtos", ProdutoController, :create
      end

      # lib/hello_web/controllers/produto_controller.ex e produto_html.ex
      defmodule HelloWeb.ProdutoController do
        use HelloWeb, :controller

        alias Hello.Lojas

        def index(conn, _params) do
          render(conn, :index, produtos: Lojas.listar_produtos(conn.assigns.current_scope))
        end

        def create(conn, %{"produto" => attrs}) do
          scope = conn.assigns.current_scope

          case Lojas.cadastrar_produto(scope, attrs) do
            {:ok, produto} ->
              conn
              |> put_flash(:info, "#{produto.nome} cadastrado.")
              |> redirect(to: ~p"/lojas/#{scope.loja}/produtos")

            {:error, _changeset} ->
              conn
              |> put_flash(:error, "O produto precisa de nome e de um preço maior que zero.")
              |> redirect(to: ~p"/lojas/#{scope.loja}/produtos")
          end
        end
      end

      defmodule HelloWeb.ProdutoHTML do
        use HelloWeb, :html

        def index(assigns) do
          ~H"""
          <Layouts.app flash={@flash} current_scope={@current_scope}>
            <.header>Produtos da loja {@current_scope.loja.nome}</.header>
            <.table id="produtos" rows={@produtos}>
              <:col :let={produto} label="Peça">{produto.nome}</:col>
              <:col :let={produto} label="Preço">{produto.preco_centavos} centavos</:col>
            </.table>
          </Layouts.app>
          """
        end
      end
      '''
    }
  end

  # --- conferência dos exercícios ----------------------------------------------

  @doc "Confere um exercício consultando o banco e fazendo requisições de verdade."
  def conferir(id) do
    resultado =
      Enum.reduce_while(Map.fetch!(verificacoes(), id), [], fn {descricao, teste}, feitos ->
        case executar(teste) do
          :ok -> {:cont, ["- Passou: " <> descricao | feitos]}
          {:erro, dica} -> {:halt, ["- **Ainda não:** " <> descricao <> "\n\n  " <> dica | feitos]}
        end
      end)

    Kino.Markdown.new(resultado |> Enum.reverse() |> Enum.join("\n"))
  end

  defp executar(teste) do
    teste.()
  rescue
    erro -> {:erro, explicar(erro)}
  end

  defp verificacoes do
    %{
      rotas_posts: [
        {"Sem login, `GET /posts` manda para `/users/log-in`: a rota existe e está atrás de `require_authenticated_user`.",
         fn ->
           case requisicao(:get, "/posts") do
             {:ok, %{status: 302} = conn} ->
               if Plug.Conn.get_resp_header(conn, "location") == ["/users/log-in"],
                 do: :ok,
                 else: {:erro, "A resposta redirecionou para `#{inspect(Plug.Conn.get_resp_header(conn, "location"))}`."}

             {:erro, %Phoenix.Router.NoRouteError{}} ->
               {:erro, "A rota `/posts` não existe. Acrescente `resources \"/posts\", PostController` à célula do router e avalie-a."}

             {:erro, %FunctionClauseError{function: :list_posts}} ->
               {:erro,
                "A rota existe, mas está num bloco sem `:require_authenticated_user`: sem login, `current_scope` é `nil`, e `Blog.list_posts(nil)` não casa com `%Scope{}`. Mova a rota para o bloco autenticado."}

             {:ok, conn} ->
               {:erro, "A resposta veio com status `#{conn.status}`; o esperado era um redirecionamento para o login."}

             {:erro, erro} ->
               {:erro, explicar(erro)}
           end
         end},
        {"A Ana cria um post por `POST /posts`, e ele fica com o `user_id` dela.",
         fn ->
           limpando(fn ->
             :ok = logado("conferência ana", "ana@example.com")
             visitar("conferência ana", :post, "/posts", %{"post" => %{"title" => "Conferência da Ana", "body" => "texto"}})

             case coluna("SELECT user_id FROM posts WHERE title = 'Conferência da Ana'") do
               [id] -> if id == id_do_usuario("ana@example.com"), do: :ok, else: {:erro, "O post ficou com o `user_id` #{id}, que não é o da Ana."}
               [] -> {:erro, "O post não foi gravado. A rota de `POST /posts` está no bloco autenticado?"}
             end
           end)
         end},
        {"A Bia não vê o post da Ana em `GET /posts`.",
         fn ->
           limpando(fn ->
             :ok = logado("conferência ana", "ana@example.com")
             :ok = logado("conferência bia", "bia@example.com")
             visitar("conferência ana", :post, "/posts", %{"post" => %{"title" => "Conferência da Ana", "body" => "texto"}})
             {:ok, conn} = visitar("conferência bia", :get, "/posts")

             if corpo(conn) =~ "Conferência da Ana",
               do: {:erro, "A lista da Bia mostra o post da Ana."},
               else: :ok
           end)
         end}
      ],
      assign_scope: [
        {"Um visitante novo recebe um `%Hello.Scope{}` em `conn.assigns.session_scope`.",
         fn ->
           esquecer("conferência sessão")

           case visitar("conferência sessão", :get, "/") do
             {:ok, %{assigns: %{session_scope: %{__struct__: Hello.Scope, id: id}}}} when is_integer(id) ->
               :ok

             {:ok, conn} ->
               {:erro,
                "Depois do pipeline, `conn.assigns.session_scope` é `#{inspect(conn.assigns[:session_scope])}`. O plug precisa fazer `assign(conn, :session_scope, Hello.Scope.for_id(id))`."}

             {:erro, erro} ->
               {:erro, explicar(erro)}
           end
         end},
        {"O mesmo visitante mantém o mesmo escopo na requisição seguinte: o id fica na sessão.",
         fn ->
           esquecer("conferência sessão")
           {:ok, primeira} = visitar("conferência sessão", :get, "/")
           {:ok, segunda} = visitar("conferência sessão", :get, "/")

           if primeira.assigns.session_scope.id == segunda.assigns.session_scope.id,
             do: :ok,
             else: {:erro, "Cada requisição ganhou um id novo. Guarde o id com `put_session(conn, :scope_id, id)` e leia-o com `get_session(conn, :scope_id)`."}
         end},
        {"Dois visitantes têm escopos diferentes, e um não vê a nota do outro.",
         fn ->
           limpando(fn ->
             esquecer("conferência carla")
             esquecer("conferência davi")
             visitar("conferência carla", :post, "/notes", %{"note" => %{"title" => "Conferência nota da Carla"}})
             {:ok, da_carla} = visitar("conferência carla", :get, "/notes")
             {:ok, do_davi} = visitar("conferência davi", :get, "/notes")

             cond do
               not (corpo(da_carla) =~ "Conferência nota da Carla") -> {:erro, "A nota criada não aparece nem para quem a criou."}
               corpo(do_davi) =~ "Conferência nota da Carla" -> {:erro, "O Davi vê a nota da Carla: os dois receberam o mesmo escopo."}
               true -> :ok
             end
           end)
         end}
      ],
      org_por_slug: [
        {"`get_organization_by_slug!/2` devolve a organização para quem é membro dela.",
         fn ->
           limpando(fn ->
             org_de_teste("conferencia-a", "ana@example.com")
             org = apply(Hello.Accounts, :get_organization_by_slug!, [escopo_de("ana@example.com"), "conferencia-a"])
             if org.slug == "conferencia-a", do: :ok, else: {:erro, "Veio `#{inspect(org)}`."}
           end)
         end},
        {"Para quem não é membro, levanta `Ecto.NoResultsError`.",
         fn ->
           limpando(fn ->
             org_de_teste("conferencia-a", "ana@example.com")

             case levanta(fn -> apply(Hello.Accounts, :get_organization_by_slug!, [escopo_de("bia@example.com"), "conferencia-a"]) end) do
               %Ecto.NoResultsError{} ->
                 :ok

               :nao_levantou ->
                 {:erro,
                  "A função devolveu a organização para a Bia, que não é membro. Junte `organizations_users` (`join: u in assoc(o, :users)`) e filtre por `u.id == ^scope.user.id`."}

               erro ->
                 {:erro, explicar(erro)}
             end
           end)
         end},
        {"Pela web, a Ana não abre os artigos de uma organização de que não é membro.",
         fn ->
           limpando(fn ->
             org_de_teste("conferencia-b", "bia@example.com")
             :ok = logado("conferência ana", "ana@example.com")

             case visitar("conferência ana", :get, "/organizations/conferencia-b/articles") do
               {:erro, %Ecto.NoResultsError{}} -> :ok
               {:ok, conn} -> {:erro, "A Ana recebeu status `#{conn.status}` numa organização que não é dela."}
               {:erro, erro} -> {:erro, explicar(erro)}
             end
           end)
         end},
        {"E abre os da organização dela.",
         fn ->
           limpando(fn ->
             org_de_teste("conferencia-a", "ana@example.com")
             :ok = logado("conferência ana", "ana@example.com")

             case visitar("conferência ana", :get, "/organizations/conferencia-a/articles") do
               {:ok, %{status: 200}} -> :ok
               {:ok, conn} -> {:erro, "A resposta veio com status `#{conn.status}`."}
               {:erro, erro} -> {:erro, explicar(erro)}
             end
           end)
         end}
      ],
      scope_for: [
        {"`Hello.Accounts.Scope` define `for/1`.", fn -> exporta(Hello.Accounts.Scope, for: 1) end},
        {"`Scope.for(user: \"ana@example.com\")` traz a Ana e nenhuma organização.",
         fn ->
           case apply(Hello.Accounts.Scope, :for, [[user: "ana@example.com"]]) do
             %{user: %{email: "ana@example.com"}, organization: nil} -> :ok
             outro -> {:erro, "Veio `#{inspect(outro, limit: 6)}`."}
           end
         end},
        {"`Scope.for(user: id, org: slug)` traz o usuário pelo id e a organização pelo slug.",
         fn ->
           limpando(fn ->
             org_de_teste("conferencia-a", "ana@example.com")
             id = id_do_usuario("ana@example.com")

             case apply(Hello.Accounts.Scope, :for, [[user: id, org: "conferencia-a"]]) do
               %{user: %{id: ^id}, organization: %{slug: "conferencia-a"}} -> :ok
               outro -> {:erro, "Veio `#{inspect(outro, limit: 6)}`."}
             end
           end)
         end},
        {"`Scope.for(org: slug)` traz só a organização.",
         fn ->
           limpando(fn ->
             org_de_teste("conferencia-a", "ana@example.com")

             case apply(Hello.Accounts.Scope, :for, [[org: "conferencia-a"]]) do
               %{user: nil, organization: %{slug: "conferencia-a"}} -> :ok
               outro -> {:erro, "Veio `#{inspect(outro, limit: 6)}`."}
             end
           end)
         end}
      ],
      brecho: [
        {"As tabelas `lojas`, `lojas_usuarios` e `produtos` existem, com as colunas pedidas.",
         fn ->
           with :ok <- colunas("lojas", ~w(id nome slug inserted_at updated_at)),
                :ok <- colunas("lojas_usuarios", ~w(loja_id user_id)) do
             colunas("produtos", ~w(id nome preco_centavos loja_id inserted_at updated_at))
           end
         end},
        {"`Hello.Lojas.Loja` usa o slug nas rotas (`Phoenix.Param`).",
         fn ->
           case Phoenix.Param.to_param(struct(Hello.Lojas.Loja, id: 7, slug: "brecho-da-ana")) do
             "brecho-da-ana" -> :ok
             outro -> {:erro, "`Phoenix.Param.to_param/1` devolveu `#{inspect(outro)}`. Use `@derive {Phoenix.Param, key: :slug}`."}
           end
         end},
        {"`Hello.Accounts.Scope.put_loja/2` guarda a loja no escopo.",
         fn ->
           escopo = apply(Hello.Accounts.Scope, :put_loja, [escopo_de("ana@example.com"), struct(Hello.Lojas.Loja, slug: "x")])
           if match?(%{loja: %{slug: "x"}}, escopo), do: :ok, else: {:erro, "Veio `#{inspect(escopo, limit: 6)}`."}
         end},
        {"`Hello.Lojas.buscar_loja!/2` só encontra a loja para quem é membro.",
         fn ->
           limpando(fn ->
             loja_de_teste("conferencia-loja-ana", "ana@example.com")
             loja = apply(Hello.Lojas, :buscar_loja!, [escopo_de("ana@example.com"), "conferencia-loja-ana"])

             case levanta(fn -> apply(Hello.Lojas, :buscar_loja!, [escopo_de("bia@example.com"), "conferencia-loja-ana"]) end) do
               %Ecto.NoResultsError{} when loja.slug == "conferencia-loja-ana" -> :ok
               :nao_levantou -> {:erro, "A Bia encontrou a loja da Ana. Filtre pelos membros, como em `get_organization_by_slug!/2`."}
               erro -> {:erro, explicar(erro)}
             end
           end)
         end},
        {"`listar_produtos/1` e `cadastrar_produto/2` usam a loja do escopo.",
         fn ->
           limpando(fn ->
             id_ana = loja_de_teste("conferencia-loja-ana", "ana@example.com")
             loja_de_teste("conferencia-loja-bia", "bia@example.com")
             ana = escopo_de("ana@example.com")
             bia = escopo_de("bia@example.com")
             loja_ana = apply(Hello.Lojas, :buscar_loja!, [ana, "conferencia-loja-ana"])
             loja_bia = apply(Hello.Lojas, :buscar_loja!, [bia, "conferencia-loja-bia"])
             escopo_ana = apply(Hello.Accounts.Scope, :put_loja, [ana, loja_ana])
             escopo_bia = apply(Hello.Accounts.Scope, :put_loja, [bia, loja_bia])

             {:ok, produto} = apply(Hello.Lojas, :cadastrar_produto, [escopo_ana, %{"nome" => "Conferência casaco", "preco_centavos" => "9000"}])
             {:ok, _} = apply(Hello.Lojas, :cadastrar_produto, [escopo_bia, %{"nome" => "Conferência bota", "preco_centavos" => "5000"}])
             nomes_ana = apply(Hello.Lojas, :listar_produtos, [escopo_ana]) |> Enum.map(& &1.nome)

             cond do
               produto.loja_id != id_ana -> {:erro, "O produto ficou com `loja_id` #{inspect(produto.loja_id)}, não com o da loja do escopo."}
               nomes_ana != ["Conferência casaco"] -> {:erro, "A lista da loja da Ana veio `#{inspect(nomes_ana)}`."}
               true -> :ok
             end
           end)
         end},
        {"Pela web, a Ana vê os produtos da loja dela, e só eles.",
         fn ->
           limpando(fn ->
             id_ana = loja_de_teste("conferencia-loja-ana", "ana@example.com")
             id_bia = loja_de_teste("conferencia-loja-bia", "bia@example.com")

             for {nome, loja} <- [{"Conferência vestido", id_ana}, {"Conferência chapéu", id_bia}] do
               sql("INSERT INTO produtos (nome, preco_centavos, loja_id, inserted_at, updated_at) VALUES ($1, 1000, $2, now(), now())", [nome, loja])
             end

             :ok = logado("conferência ana", "ana@example.com")

             case visitar("conferência ana", :get, "/lojas/conferencia-loja-ana/produtos") do
               {:ok, %{status: 200} = conn} ->
                 cond do
                   not (corpo(conn) =~ "Conferência vestido") -> {:erro, "A página não mostra o produto da loja."}
                   corpo(conn) =~ "Conferência chapéu" -> {:erro, "A página mostra um produto de outra loja."}
                   true -> :ok
                 end

               {:ok, conn} ->
                 {:erro, "A resposta veio com status `#{conn.status}`."}

               {:erro, erro} ->
                 {:erro, explicar(erro)}
             end
           end)
         end},
        {"A Bia, que não é membro, recebe `Ecto.NoResultsError` (404 em produção); sem login, vai para `/users/log-in`.",
         fn ->
           limpando(fn ->
             loja_de_teste("conferencia-loja-ana", "ana@example.com")
             :ok = logado("conferência bia", "bia@example.com")

             with {:erro, %Ecto.NoResultsError{}} <- visitar("conferência bia", :get, "/lojas/conferencia-loja-ana/produtos"),
                  {:ok, %{status: 302} = conn} <- requisicao(:get, "/lojas/conferencia-loja-ana/produtos"),
                  ["/users/log-in"] <- Plug.Conn.get_resp_header(conn, "location") do
               :ok
             else
               {:ok, %{status: status}} -> {:erro, "Uma das requisições respondeu `#{status}`. Ponha o plug da loja depois de `:require_authenticated_user`."}
               {:erro, erro} -> {:erro, explicar(erro)}
               outro -> {:erro, "Resposta inesperada: `#{inspect(outro, limit: 5)}`."}
             end
           end)
         end},
        {"A Ana cadastra um produto por `POST /lojas/SLUG/produtos` e volta para a lista.",
         fn ->
           limpando(fn ->
             id_ana = loja_de_teste("conferencia-loja-ana", "ana@example.com")
             :ok = logado("conferência ana", "ana@example.com")
             caminho = "/lojas/conferencia-loja-ana/produtos"

             parametros = %{"produto" => %{"nome" => "Conferência saia", "preco_centavos" => "3500"}}

             case requisicao(:post, caminho, parametros, conn_de("conferência ana")) do
               {:ok, %{status: 302} = conn} ->
                 cond do
                   Plug.Conn.get_resp_header(conn, "location") != [caminho] -> {:erro, "Redirecionou para `#{inspect(Plug.Conn.get_resp_header(conn, "location"))}`."}
                   coluna("SELECT loja_id FROM produtos WHERE nome = 'Conferência saia'") != [id_ana] -> {:erro, "O produto não foi gravado na loja da Ana."}
                   true -> :ok
                 end

               {:ok, conn} ->
                 {:erro, "A resposta veio com status `#{conn.status}`."}

               {:erro, erro} ->
                 {:erro, explicar(erro)}
             end
           end)
         end}
      ]
    }
  end

  # --- ajudantes da conferência --------------------------------------------------

  defp exporta(modulo, funcoes) do
    faltando = Enum.reject(funcoes, fn {nome, aridade} -> Code.ensure_loaded?(modulo) and function_exported?(modulo, nome, aridade) end)

    if faltando == [],
      do: :ok,
      else: {:erro, "Falta definir em `#{inspect(modulo)}`: #{Enum.map_join(faltando, ", ", fn {n, a} -> "`#{n}/#{a}`" end)}."}
  end

  defp colunas(tabela, esperadas) do
    existentes = coluna("SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1", [tabela])

    cond do
      existentes == [] -> {:erro, "A tabela `#{tabela}` não existe. Defina a migration, registre-a e rode a célula do `mix ecto.migrate`."}
      (faltando = esperadas -- existentes) != [] -> {:erro, "A tabela `#{tabela}` não tem as colunas #{inspect(faltando)}."}
      true -> :ok
    end
  end

  defp coluna(consulta, parametros \\ []), do: consulta |> sql(parametros) |> Map.fetch!(:rows) |> Enum.map(&hd/1)

  defp explicar(%Phoenix.Router.NoRouteError{} = erro) do
    "Etapa: **router**. `#{Exception.message(erro)}`. Adicione a rota na célula do router e avalie-a de novo."
  end

  defp explicar(%UndefinedFunctionError{module: modulo, function: funcao, arity: aridade}) do
    nome = inspect(modulo)

    cond do
      String.ends_with?(nome, "Controller") and funcao == :init ->
        "Etapa: **controller**. A rota aponta para `#{nome}`, mas esse módulo não existe. Defina-o e avalie a célula."

      String.ends_with?(nome, "Controller") ->
        "Etapa: **controller**. `#{nome}` existe, mas não define `#{funcao}/#{aridade}`."

      String.ends_with?(nome, "HTML") ->
        "Etapa: **view**. O `render` procurou `#{nome}.#{funcao}/#{aridade}` e não achou."

      true ->
        "`#{nome}.#{funcao}/#{aridade}` não existe. O módulo foi definido e a célula foi avaliada?"
    end
  end

  defp explicar(%Postgrex.Error{postgres: %{code: :undefined_table, message: mensagem}}) do
    "O PostgreSQL respondeu `#{mensagem}`. A migration que cria a tabela já rodou?"
  end

  defp explicar(%Ecto.ConstraintError{constraint: restricao}) do
    "O banco recusou a operação pela restrição `#{restricao}`, e o changeset não a declara: a violação virou exceção em vez de erro no changeset."
  end

  defp explicar(%Phoenix.ActionClauseError{}) do
    "Etapa: **controller**. Nenhuma cláusula da action casou com os parâmetros recebidos."
  end

  defp explicar(%MatchError{term: termo}) do
    "Um casamento de padrões falhou com o valor `#{inspect(termo, limit: 8)}`."
  end

  defp explicar(erro), do: "Levantou uma exceção:\n\n  `" <> Exception.format_banner(:error, erro) <> "`"
end

Como usar este notebook

Este notebook acompanha o guia Scopes da documentação do Phoenix 1.8, na mesma ordem e com o link de cada seção. É o segundo notebook do módulo de autenticação. O primeiro, o 13, gerou a autenticação com mix phx.gen.auth; este começa com ela pronta e estuda a peça que o Phoenix 1.8 trouxe junto: o escopo, a estrutura que diz quem faz cada requisição e que todo contexto gerado usa para filtrar os dados.

As convenções são as de sempre: uma célula equivale a um arquivo, as perguntas "Antes de continuar" vêm antes da explicação, e os erros fazem parte do conteúdo. A lista de dependências da primeira célula é a do notebook 13, maior que a dos notebooks 01 a 12: ela acrescenta o Swoosh (e-mails) e o pbkdf2_elixir (senhas). Se você ainda não abriu o 13, a primeira avaliação faz uma instalação nova, uma vez só.

O guia usa mix phx.gen.live para gerar os recursos com escopo. O LiveView é assunto de um módulo mais adiante, então aqui o equivalente é mix phx.gen.html, que gera controllers e templates. O contexto, o schema e a migration são os mesmos nas duas variantes; o código mostrado é o que o Phoenix 1.8.16 gera de verdade, rodado num projeto.

Quatro visitantes aparecem nas requisições: Ana e Bia, usuárias com conta (a senha das duas é senha da trilha), e Carla e Davi, que nunca fazem login. Trilha.navegar/4 guarda os cookies de cada um, como um navegador. Você também pode abrir http://localhost:4010 no navegador e entrar como ana@example.com.

A aplicação fica em http://localhost:4010. Se outro notebook da trilha estiver com o runtime ligado, desconecte-o antes.

A aplicação Hello dentro do notebook

As células desta seção repetem a aplicação do notebook 13: a base dos notebooks anteriores e, mais abaixo, a autenticação gerada.

config/dev.exs

Application.put_env(:hello, Hello.Repo,
  username: System.get_env("PGUSER", "postgres"),
  password: System.get_env("PGPASSWORD", "postgres"),
  hostname: System.get_env("PGHOST", "localhost"),
  port: String.to_integer(System.get_env("PGPORT", "5432")),
  database: "trilha_phoenix_dev",
  show_sensitive_data_on_connection_error: true,
  pool_size: 10,
  stacktrace: true
)

lib/hello/repo.ex

defmodule Hello.Repo do
  use Ecto.Repo,
    otp_app: :hello,
    adapter: Ecto.Adapters.Postgres
end

lib/hello_web.ex

defmodule HelloWeb do
  def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)

  def router do
    quote do
      use Phoenix.Router, helpers: false

      import Plug.Conn
      import Phoenix.Controller
      import Phoenix.LiveView.Router
    end
  end

  def controller do
    quote do
      use Phoenix.Controller, formats: [:html, :json]

      import Plug.Conn

      unquote(verified_routes())
    end
  end

  def html do
    quote do
      use Phoenix.Component

      import Phoenix.Controller,
        only: [get_csrf_token: 0, view_module: 1, view_template: 1]

      unquote(html_helpers())
    end
  end

  defp html_helpers do
    quote do
      import Phoenix.HTML
      import HelloWeb.CoreComponents

      alias Phoenix.LiveView.JS
      alias HelloWeb.Layouts

      unquote(verified_routes())
    end
  end

  def verified_routes do
    quote do
      use Phoenix.VerifiedRoutes,
        endpoint: HelloWeb.Endpoint,
        router: HelloWeb.Router,
        statics: HelloWeb.static_paths()
    end
  end

  defmacro __using__(which) when is_atom(which) do
    apply(__MODULE__, which, [])
  end
end

lib/hello_web/router.ex: o router da aplicação recém-criada. Ele vem antes do UserAuth porque o UserAuth usa ~p, que confere os caminhos contra um router que já exista. A versão com as rotas de autenticação vem logo depois do UserAuth.

defmodule HelloWeb.Router do
  use HelloWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {HelloWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :home
  end
end

lib/hello_web/endpoint.ex

defmodule HelloWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :hello

  @session_options [
    store: :cookie,
    key: "_hello_key",
    signing_salt: "trilha00",
    same_site: "Lax"
  ]

  plug Plug.Static,
    at: "/assets",
    from: {:phoenix_html, "priv/static"},
    only: ~w(phoenix_html.js)

  plug Plug.RequestId
  plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]

  plug Plug.Parsers,
    parsers: [:urlencoded, :multipart, :json],
    pass: ["*/*"],
    json_decoder: Phoenix.json_library()

  plug Plug.MethodOverride
  plug Plug.Head
  plug Plug.Session, @session_options
  plug HelloWeb.Router
end

lib/hello_web/components/core_components.ex: os componentes gerados pelo Phoenix 1.8.16, sem o Gettext.

defmodule HelloWeb.CoreComponents do
  use Phoenix.Component

  alias Phoenix.LiveView.JS

  @doc "Renders flash notices."
  attr :id, :string, doc: "the optional id of flash container"
  attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
  attr :title, :string, default: nil
  attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
  attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"

  slot :inner_block, doc: "the optional inner block that renders the flash message"

  def flash(assigns) do
    assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)

    ~H"""
    <div
      :if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
      id={@id}
      phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
      role="alert"
      class="toast toast-top toast-end z-50"
      {@rest}
    >
      <div class={[
        "alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
        @kind == :info && "alert-info",
        @kind == :error && "alert-error"
      ]}>
        <.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
        <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
        <div>
          <p :if={@title} class="font-semibold">{@title}</p>
          <p>{msg}</p>
        </div>
        <div class="flex-1" />
        <button type="button" class="group self-start cursor-pointer" aria-label="close">
          <.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
        </button>
      </div>
    </div>
    """
  end

  @doc "Renders a button with navigation support."
  attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
  attr :class, :any
  attr :variant, :string, values: ~w(primary)
  slot :inner_block, required: true

  def button(%{rest: rest} = assigns) do
    variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}

    assigns =
      assign_new(assigns, :class, fn ->
        ["btn", Map.fetch!(variants, assigns[:variant])]
      end)

    if rest[:href] || rest[:navigate] || rest[:patch] do
      ~H"""
      <.link class={@class} {@rest}>
        {render_slot(@inner_block)}
      </.link>
      """
    else
      ~H"""
      <button class={@class} {@rest}>
        {render_slot(@inner_block)}
      </button>
      """
    end
  end

  @doc "Renders an input with label and error messages."
  attr :id, :any, default: nil
  attr :name, :any
  attr :label, :string, default: nil
  attr :value, :any

  attr :type, :string,
    default: "text",
    values: ~w(checkbox color date datetime-local email file month number password
               search select tel text textarea time url week hidden)

  attr :field, Phoenix.HTML.FormField,
    doc: "a form field struct retrieved from the form, for example: @form[:email]"

  attr :errors, :list, default: []
  attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
  attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
  attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
  attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
  attr :class, :any, default: nil, doc: "the input class to use over defaults"
  attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"

  attr :rest, :global,
    include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
                multiple pattern placeholder readonly required rows size step)

  def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
    errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []

    assigns
    |> assign(field: nil, id: assigns.id || field.id)
    |> assign(:errors, Enum.map(errors, &translate_error(&1)))
    |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
    |> assign_new(:value, fn -> field.value end)
    |> input()
  end

  def input(%{type: "hidden"} = assigns) do
    ~H"""
    <input type="hidden" id={@id} name={@name} value={@value} {@rest} />
    """
  end

  def input(%{type: "checkbox"} = assigns) do
    assigns =
      assign_new(assigns, :checked, fn ->
        Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
      end)

    ~H"""
    <div class="fieldset mb-2">
      <label for={@id}>
        <input
          type="hidden"
          name={@name}
          value="false"
          disabled={@rest[:disabled]}
          form={@rest[:form]}
        />
        <span class="label">
          <input
            type="checkbox"
            id={@id}
            name={@name}
            value="true"
            checked={@checked}
            class={@class || "checkbox checkbox-sm"}
            {@rest}
          />{@label}
        </span>
      </label>
      <.error :for={msg <- @errors}>{msg}</.error>
    </div>
    """
  end

  def input(%{type: "select"} = assigns) do
    ~H"""
    <div class="fieldset mb-2">
      <label for={@id}>
        <span :if={@label} class="label mb-1">{@label}</span>
        <select
          id={@id}
          name={@name}
          class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
          multiple={@multiple}
          {@rest}
        >
          <option :if={@prompt} value="">{@prompt}</option>
          {Phoenix.HTML.Form.options_for_select(@options, @value)}
        </select>
      </label>
      <.error :for={msg <- @errors}>{msg}</.error>
    </div>
    """
  end

  def input(%{type: "textarea"} = assigns) do
    ~H"""
    <div class="fieldset mb-2">
      <label for={@id}>
        <span :if={@label} class="label mb-1">{@label}</span>
        <textarea
          id={@id}
          name={@name}
          class={[
            @class || "w-full textarea",
            @errors != [] && (@error_class || "textarea-error")
          ]}
          {@rest}
        >{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
      </label>
      <.error :for={msg <- @errors}>{msg}</.error>
    </div>
    """
  end

  # All other inputs text, datetime-local, url, password, etc. are handled here...
  def input(assigns) do
    ~H"""
    <div class="fieldset mb-2">
      <label for={@id}>
        <span :if={@label} class="label mb-1">{@label}</span>
        <input
          type={@type}
          name={@name}
          id={@id}
          value={Phoenix.HTML.Form.normalize_value(@type, @value)}
          class={[
            @class || "w-full input",
            @errors != [] && (@error_class || "input-error")
          ]}
          {@rest}
        />
      </label>
      <.error :for={msg <- @errors}>{msg}</.error>
    </div>
    """
  end

  # Helper used by inputs to generate form errors
  defp error(assigns) do
    ~H"""
    <p class="mt-1.5 flex gap-2 items-center text-sm text-error">
      <.icon name="hero-exclamation-circle" class="size-5" />
      {render_slot(@inner_block)}
    </p>
    """
  end

  @doc "Renders a header with title."
  slot :inner_block, required: true
  slot :subtitle
  slot :actions

  def header(assigns) do
    ~H"""
    <header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
      <div>
        <h1 class="text-lg font-semibold leading-8">
          {render_slot(@inner_block)}
        </h1>
        <p :if={@subtitle != []} class="text-sm text-base-content/70">
          {render_slot(@subtitle)}
        </p>
      </div>
      <div class="flex-none">{render_slot(@actions)}</div>
    </header>
    """
  end

  @doc "Renders a table with generic styling."
  attr :id, :string, required: true
  attr :rows, :list, required: true
  attr :row_id, :any, default: nil, doc: "the function for generating the row id"
  attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"

  attr :row_item, :any,
    default: &Function.identity/1,
    doc: "the function for mapping each row before calling the :col and :action slots"

  slot :col, required: true do
    attr :label, :string
  end

  slot :action, doc: "the slot for showing user actions in the last table column"

  def table(assigns) do
    assigns =
      with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
        assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
      end

    ~H"""
    <table class="table table-zebra">
      <thead>
        <tr>
          <th :for={col <- @col}>{col[:label]}</th>
          <th :if={@action != []}>
            <span class="sr-only">Actions</span>
          </th>
        </tr>
      </thead>
      <tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
        <tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
          <td
            :for={col <- @col}
            phx-click={@row_click && @row_click.(row)}
            class={@row_click && "hover:cursor-pointer"}
          >
            {render_slot(col, @row_item.(row))}
          </td>
          <td :if={@action != []} class="w-0 font-semibold">
            <div class="flex gap-4">
              <%= for action <- @action do %>
                {render_slot(action, @row_item.(row))}
              <% end %>
            </div>
          </td>
        </tr>
      </tbody>
    </table>
    """
  end

  @doc "Renders a data list."
  slot :item, required: true do
    attr :title, :string, required: true
  end

  def list(assigns) do
    ~H"""
    <ul class="list">
      <li :for={item <- @item} class="list-row">
        <div class="list-col-grow">
          <div class="font-bold">{item.title}</div>
          <div>{render_slot(item)}</div>
        </div>
      </li>
    </ul>
    """
  end

  @doc "Renders a [Heroicon](https://heroicons.com)."
  attr :name, :string, required: true
  attr :class, :any, default: "size-4"

  def icon(%{name: "hero-" <> _} = assigns) do
    ~H"""
    <span class={[@name, @class]} />
    """
  end

  ## JS Commands

  def show(js \\ %JS{}, selector) do
    JS.show(js,
      to: selector,
      time: 300,
      transition:
        {"transition-all ease-out duration-300",
         "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
         "opacity-100 translate-y-0 sm:scale-100"}
    )
  end

  def hide(js \\ %JS{}, selector) do
    JS.hide(js,
      to: selector,
      time: 200,
      transition:
        {"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
         "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
    )
  end

  @doc """
  Fills in the error message values. In the generated project this function
  translates the message with Gettext.
  """
  def translate_error({msg, opts}) do
    Enum.reduce(opts, msg, fn {key, value}, acc ->
      String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
    end)
  end

  @doc "Translates the errors for a field from a keyword list of errors."
  def translate_errors(errors, field) when is_list(errors) do
    for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
  end
end

lib/hello_web/components/layouts.ex: o root layout já tem o menu que o phx.gen.auth injeta: o e-mail de quem está logado, "Settings" e "Log out", ou "Register" e "Log in".

defmodule HelloWeb.Layouts do
  use HelloWeb, :html

  def root(assigns) do
    ~H"""
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <meta name="csrf-token" content={get_csrf_token()} />
        <title>Hello · Phoenix Framework</title>
        <link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
        <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4">
        </script>
        <script defer src={~p"/assets/phoenix_html.js"}>
        </script>
      </head>
      <body>
        <ul class="menu menu-horizontal w-full relative z-10 flex items-center gap-4 px-4 sm:px-6 lg:px-8 justify-end">
          <%= if @current_scope do %>
            <li>
              {@current_scope.user.email}
            </li>
            <li>
              <.link href={~p"/users/settings"}>Settings</.link>
            </li>
            <li>
              <.link href={~p"/users/log-out"} method="delete">Log out</.link>
            </li>
          <% else %>
            <li>
              <.link href={~p"/users/register"}>Register</.link>
            </li>
            <li>
              <.link href={~p"/users/log-in"}>Log in</.link>
            </li>
          <% end %>
        </ul>
        {@inner_content}
      </body>
    </html>
    """
  end

  attr :flash, :map, required: true, doc: "the map of flash messages"
  attr :current_scope, :map, default: nil, doc: "the current scope"
  slot :inner_block, required: true

  def app(assigns) do
    ~H"""
    <header class="navbar px-4 sm:px-6 lg:px-8">
      <div class="flex-1">
        <a href="/" class="flex-1 flex w-fit items-center gap-2">
          <span class="text-sm font-semibold">Hello · Phoenix v{Application.spec(:phoenix, :vsn)}</span>
        </a>
      </div>
    </header>

    <main class="px-4 py-20 sm:px-6 lg:px-8">
      <div class="mx-auto max-w-2xl space-y-4">
        {render_slot(@inner_block)}
      </div>
    </main>

    <.flash_group flash={@flash} />
    """
  end

  attr :flash, :map, required: true, doc: "the map of flash messages"
  attr :id, :string, default: "flash-group", doc: "the optional id of flash container"

  def flash_group(assigns) do
    ~H"""
    <div id={@id} aria-live="polite">
      <.flash kind={:info} flash={@flash} />
      <.flash kind={:error} flash={@flash} />
    </div>
    """
  end
end

lib/hello_web/controllers/: as páginas de erro e a página inicial.

defmodule HelloWeb.ErrorHTML do
  def render(template, _assigns) do
    Phoenix.Controller.status_message_from_template(template)
  end
end

defmodule HelloWeb.ErrorJSON do
  def render(template, _assigns) do
    %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
  end
end

defmodule HelloWeb.PageController do
  use HelloWeb, :controller

  def home(conn, _params) do
    render(conn, :home)
  end
end

defmodule HelloWeb.PageHTML do
  use HelloWeb, :html

  def home(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <h1>Welcome to Phoenix!</h1>
    </Layouts.app>
    """
  end
end

Antes de começar: o PostgreSQL

O notebook usa o banco trilha_phoenix_dev, como os anteriores. A célula abaixo tenta uma conexão e, se falhar, traduz a mensagem em uma dica.

Trilha.verificar_postgres()
Ecto.Adapters.Postgres.storage_up(Hello.Repo.config())

lib/hello/application.ex: o Repo, o PubSub e o endpoint. O PubSub vai aparecer de novo neste notebook: os contextos com escopo avisam por ele quando os dados mudam.

{:ok, _} = Kino.start_child(Hello.Repo)
{:ok, _} = Kino.start_child({Phoenix.PubSub, name: Hello.PubSub})
{:ok, _} = Kino.start_child(HelloWeb.Endpoint)

Kino.Markdown.new("Repo conectado e aplicação no ar em <http://localhost:4010>.")

Comece do zero. A célula abaixo apaga as tabelas do banco da trilha, para que os id sejam os mesmos do texto. Avalie-a sempre que reabrir o notebook.

Trilha.recomecar()

A migration da autenticação cria a extensão citext, que no Fedora vem no pacote postgresql-contrib. A célula confere se o servidor a tem:

Trilha.verificar_citext()

A autenticação do notebook 13

As células abaixo são o código que mix phx.gen.auth Accounts User users --no-live --hashing-lib pbkdf2 gerou no notebook 13, na ordem em que compilam. Os @doc gerados foram tirados para encurtar, exceto no Scope, que é o assunto deste notebook. O 13 explica cada módulo.

Dois tipos de aviso aparecem ao avaliar estas células, e os dois são esperados. O UserAuth compila antes do router que tem as rotas de autenticação, então o compilador avisa no route path for HelloWeb.Router matches "/users/log-in". E cada router compila antes dos controllers para onde aponta, então avisa HelloWeb.UserSessionController.init/1 is undefined. Nos dois casos, as peças existem quando a primeira requisição chega. O mesmo vale para os routers mais abaixo.

defmodule Hello.Mailer do
  use Swoosh.Mailer, otp_app: :hello
end
defmodule Hello.Repo.Migrations.CreateUsersAuthTables do
  use Ecto.Migration

  def change do
    execute "CREATE EXTENSION IF NOT EXISTS citext", ""

    create table(:users) do
      add :email, :citext, null: false
      add :hashed_password, :string
      add :confirmed_at, :utc_datetime

      timestamps(type: :utc_datetime)
    end

    create unique_index(:users, [:email])

    create table(:users_tokens) do
      add :user_id, references(:users, on_delete: :delete_all), null: false
      add :token, :binary, null: false
      add :context, :string, null: false
      add :sent_to, :string
      add :authenticated_at, :utc_datetime

      timestamps(type: :utc_datetime, updated_at: false)
    end

    create index(:users_tokens, [:user_id])
    create unique_index(:users_tokens, [:context, :token])
  end
end

Trilha.migracao(20250610120000, Hello.Repo.Migrations.CreateUsersAuthTables)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
defmodule Hello.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :email, :string
    field :password, :string, virtual: true, redact: true
    field :hashed_password, :string, redact: true
    field :confirmed_at, :utc_datetime
    field :authenticated_at, :utc_datetime, virtual: true

    timestamps(type: :utc_datetime)
  end

  def email_changeset(user, attrs, opts \\ []) do
    user
    |> cast(attrs, [:email])
    |> validate_email(opts)
  end

  defp validate_email(changeset, opts) do
    changeset =
      changeset
      |> validate_required([:email])
      |> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/,
        message: "must have the @ sign and no spaces"
      )
      |> validate_length(:email, max: 160)

    if Keyword.get(opts, :validate_unique, true) do
      changeset
      |> unsafe_validate_unique(:email, Hello.Repo)
      |> unique_constraint(:email)
      |> validate_email_changed()
    else
      changeset
    end
  end

  defp validate_email_changed(changeset) do
    if get_field(changeset, :email) && get_change(changeset, :email) == nil do
      add_error(changeset, :email, "did not change")
    else
      changeset
    end
  end

  def password_changeset(user, attrs, opts \\ []) do
    user
    |> cast(attrs, [:password])
    |> validate_confirmation(:password, message: "does not match password")
    |> validate_password(opts)
  end

  defp validate_password(changeset, opts) do
    changeset
    |> validate_required([:password])
    |> validate_length(:password, min: 12, max: 72)
    # Examples of additional password validation:
    # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
    # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
    # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
    |> maybe_hash_password(opts)
  end

  defp maybe_hash_password(changeset, opts) do
    hash_password? = Keyword.get(opts, :hash_password, true)
    password = get_change(changeset, :password)

    if hash_password? && password && changeset.valid? do
      changeset
      # Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
      # would keep the database transaction open longer and hurt performance.
      |> put_change(:hashed_password, Pbkdf2.hash_pwd_salt(password))
      |> delete_change(:password)
    else
      changeset
    end
  end

  def confirm_changeset(user) do
    now = DateTime.utc_now(:second)
    change(user, confirmed_at: now)
  end

  def valid_password?(%Hello.Accounts.User{hashed_password: hashed_password}, password)
      when is_binary(hashed_password) and byte_size(password) > 0 do
    Pbkdf2.verify_pass(password, hashed_password)
  end

  def valid_password?(_, _) do
    Pbkdf2.no_user_verify()
    false
  end
end
defmodule Hello.Accounts.UserToken do
  use Ecto.Schema
  import Ecto.Query
  alias Hello.Accounts.UserToken

  @hash_algorithm :sha256
  @rand_size 32

  # It is very important to keep the magic link token expiry short,
  # since someone with access to the email may take over the account.
  @magic_link_validity_in_minutes 15
  @change_email_validity_in_days 7
  @session_validity_in_days 14

  schema "users_tokens" do
    field :token, :binary
    field :context, :string
    field :sent_to, :string
    field :authenticated_at, :utc_datetime
    belongs_to :user, Hello.Accounts.User

    timestamps(type: :utc_datetime, updated_at: false)
  end

  def build_session_token(user) do
    token = :crypto.strong_rand_bytes(@rand_size)
    dt = user.authenticated_at || DateTime.utc_now(:second)
    {token, %UserToken{token: token, context: "session", user_id: user.id, authenticated_at: dt}}
  end

  def verify_session_token_query(token) do
    query =
      from token in by_token_and_context_query(token, "session"),
        join: user in assoc(token, :user),
        where: token.inserted_at > ago(@session_validity_in_days, "day"),
        select: {%{user | authenticated_at: token.authenticated_at}, token.inserted_at}

    {:ok, query}
  end

  def build_email_token(user, context) do
    build_hashed_token(user, context, user.email)
  end

  defp build_hashed_token(user, context, sent_to) do
    token = :crypto.strong_rand_bytes(@rand_size)
    hashed_token = :crypto.hash(@hash_algorithm, token)

    {Base.url_encode64(token, padding: false),
     %UserToken{
       token: hashed_token,
       context: context,
       sent_to: sent_to,
       user_id: user.id
     }}
  end

  def verify_magic_link_token_query(token) do
    case Base.url_decode64(token, padding: false) do
      {:ok, decoded_token} ->
        hashed_token = :crypto.hash(@hash_algorithm, decoded_token)

        query =
          from token in by_token_and_context_query(hashed_token, "login"),
            join: user in assoc(token, :user),
            where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"),
            where: token.sent_to == user.email,
            select: {user, token}

        {:ok, query}

      :error ->
        :error
    end
  end

  def verify_change_email_token_query(token, "change:" <> _ = context) do
    case Base.url_decode64(token, padding: false) do
      {:ok, decoded_token} ->
        hashed_token = :crypto.hash(@hash_algorithm, decoded_token)

        query =
          from token in by_token_and_context_query(hashed_token, context),
            where: token.inserted_at > ago(@change_email_validity_in_days, "day")

        {:ok, query}

      :error ->
        :error
    end
  end

  defp by_token_and_context_query(token, context) do
    from UserToken, where: [token: ^token, context: ^context]
  end
end

lib/hello/accounts/scope.ex: o escopo gerado, inteiro.

defmodule Hello.Accounts.Scope do
  @moduledoc """
  Defines the scope of the caller to be used throughout the app.

  The `Hello.Accounts.Scope` allows public interfaces to receive
  information about the caller, such as if the call is initiated from an
  end-user, and if so, which user. Additionally, such a scope can carry fields
  such as "super user" or other privileges for use in authorization checks,
  or to ensure specific code paths can only be accessed for a given scope.

  It is useful for logging as well as for scoping pubsub subscriptions and
  broadcasts when a caller subscribes to an interface or performs a particular
  action.

  Feel free to extend the fields on this struct to fit the needs of
  growing application requirements.
  """

  alias Hello.Accounts.User

  defstruct user: nil

  @doc """
  Creates a scope for the given user.

  Returns nil if no user is given.
  """
  def for_user(%User{} = user) do
    %__MODULE__{user: user}
  end

  def for_user(nil), do: nil
end
defmodule Hello.Accounts.UserNotifier do
  import Swoosh.Email

  alias Hello.Mailer
  alias Hello.Accounts.User

  # Delivers the email using the application mailer.
  defp deliver(recipient, subject, body) do
    email =
      new()
      |> to(recipient)
      |> from({"Hello", "contact@example.com"})
      |> subject(subject)
      |> text_body(body)

    with {:ok, _metadata} <- Mailer.deliver(email) do
      {:ok, email}
    end
  end

  def deliver_update_email_instructions(user, url) do
    deliver(user.email, "Update email instructions", """

    ==============================

    Hi #{user.email},

    You can change your email by visiting the URL below:

    #{url}

    If you didn't request this change, please ignore this.

    ==============================
    """)
  end

  def deliver_login_instructions(user, url) do
    case user do
      %User{confirmed_at: nil} -> deliver_confirmation_instructions(user, url)
      _ -> deliver_magic_link_instructions(user, url)
    end
  end

  defp deliver_magic_link_instructions(user, url) do
    deliver(user.email, "Log in instructions", """

    ==============================

    Hi #{user.email},

    You can log into your account by visiting the URL below:

    #{url}

    If you didn't request this email, please ignore this.

    ==============================
    """)
  end

  defp deliver_confirmation_instructions(user, url) do
    deliver(user.email, "Confirmation instructions", """

    ==============================

    Hi #{user.email},

    You can confirm your account by visiting the URL below:

    #{url}

    If you didn't create an account with us, please ignore this.

    ==============================
    """)
  end
end
defmodule Hello.Accounts do
  import Ecto.Query, warn: false
  alias Hello.Repo

  alias Hello.Accounts.{User, UserToken, UserNotifier}

  ## Database getters

  def get_user_by_email(email) when is_binary(email) do
    Repo.get_by(User, email: email)
  end

  def get_user_by_email_and_password(email, password)
      when is_binary(email) and is_binary(password) do
    user = Repo.get_by(User, email: email)
    if User.valid_password?(user, password), do: user
  end

  def get_user!(id), do: Repo.get!(User, id)

  ## User registration

  def register_user(attrs) do
    %User{}
    |> User.email_changeset(attrs)
    |> Repo.insert()
  end

  ## Settings

  def sudo_mode?(user, minutes \\ -20)

  def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do
    DateTime.after?(ts, DateTime.utc_now() |> DateTime.add(minutes, :minute))
  end

  def sudo_mode?(_user, _minutes), do: false

  def change_user_email(user, attrs \\ %{}, opts \\ []) do
    User.email_changeset(user, attrs, opts)
  end

  def update_user_email(user, token) do
    context = "change:#{user.email}"

    Repo.transact(fn ->
      with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
           %UserToken{sent_to: email} <- Repo.one(query),
           {:ok, user} <- Repo.update(User.email_changeset(user, %{email: email})),
           {_count, _result} <-
             Repo.delete_all(from(UserToken, where: [user_id: ^user.id, context: ^context])) do
        {:ok, user}
      else
        _ -> {:error, :transaction_aborted}
      end
    end)
  end

  def change_user_password(user, attrs \\ %{}, opts \\ []) do
    User.password_changeset(user, attrs, opts)
  end

  def update_user_password(user, attrs) do
    user
    |> User.password_changeset(attrs)
    |> update_user_and_delete_all_tokens()
  end

  ## Session

  def generate_user_session_token(user) do
    {token, user_token} = UserToken.build_session_token(user)
    Repo.insert!(user_token)
    token
  end

  def get_user_by_session_token(token) do
    {:ok, query} = UserToken.verify_session_token_query(token)
    Repo.one(query)
  end

  def get_user_by_magic_link_token(token) do
    with {:ok, query} <- UserToken.verify_magic_link_token_query(token),
         {user, _token} <- Repo.one(query) do
      user
    else
      _ -> nil
    end
  end

  def login_user_by_magic_link(token) do
    {:ok, query} = UserToken.verify_magic_link_token_query(token)

    case Repo.one(query) do
      # Prevent session fixation attacks by disallowing magic links for unconfirmed users with password
      {%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) ->
        raise """
        magic link log in is not allowed for unconfirmed users with a password set!

        This cannot happen with the default implementation, which indicates that you
        might have adapted the code to a different use case. Please make sure to read the
        "Mixing magic link and password registration" section of `mix help phx.gen.auth`.
        """

      {%User{confirmed_at: nil} = user, _token} ->
        user
        |> User.confirm_changeset()
        |> update_user_and_delete_all_tokens()

      {user, token} ->
        Repo.delete!(token)
        {:ok, {user, []}}

      nil ->
        {:error, :not_found}
    end
  end

  @doc ~S"""
  Delivers the update email instructions to the given user.

  ## Examples

      iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm-email/#{&1}"))
      {:ok, %{to: ..., body: ...}}

  """
  def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
      when is_function(update_email_url_fun, 1) do
    {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")

    Repo.insert!(user_token)
    UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
  end

  def deliver_login_instructions(%User{} = user, magic_link_url_fun)
      when is_function(magic_link_url_fun, 1) do
    {encoded_token, user_token} = UserToken.build_email_token(user, "login")
    Repo.insert!(user_token)
    UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token))
  end

  def delete_user_session_token(token) do
    Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"]))
    :ok
  end

  ## Token helper

  defp update_user_and_delete_all_tokens(changeset) do
    Repo.transact(fn ->
      with {:ok, user} <- Repo.update(changeset) do
        tokens_to_expire = Repo.all_by(UserToken, user_id: user.id)

        Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id)))

        {:ok, {user, tokens_to_expire}}
      end
    end)
  end
end
defmodule HelloWeb.UserAuth do
  use HelloWeb, :verified_routes

  import Plug.Conn
  import Phoenix.Controller

  alias Hello.Accounts
  alias Hello.Accounts.Scope

  # Make the remember me cookie valid for 14 days. This should match
  # the session validity setting in UserToken.
  @max_cookie_age_in_days 14
  @remember_me_cookie "_hello_web_user_remember_me"
  @remember_me_options [
    sign: true,
    max_age: @max_cookie_age_in_days * 24 * 60 * 60,
    same_site: "Lax"
  ]

  # How old the session token should be before a new one is issued. When a request is made
  # with a session token older than this value, then a new session token will be created
  # and the session and remember-me cookies (if set) will be updated with the new token.
  # Lowering this value will result in more tokens being created by active users. Increasing
  # it will result in less time before a session token expires for a user to get issued a new
  # token. This can be set to a value greater than `@max_cookie_age_in_days` to disable
  # the reissuing of tokens completely.
  @session_reissue_age_in_days 7

  def log_in_user(conn, user, params \\ %{}) do
    user_return_to = get_session(conn, :user_return_to)

    conn
    |> create_or_extend_session(user, params)
    |> delete_session(:user_return_to)
    |> redirect(to: user_return_to || signed_in_path(conn))
  end

  def log_out_user(conn) do
    user_token = get_session(conn, :user_token)
    user_token && Accounts.delete_user_session_token(user_token)

    if live_socket_id = get_session(conn, :live_socket_id) do
      HelloWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
    end

    conn
    |> renew_session(nil)
    |> delete_resp_cookie(@remember_me_cookie, @remember_me_options)
    |> redirect(to: ~p"/")
  end

  def fetch_current_scope_for_user(conn, _opts) do
    with {token, conn} <- ensure_user_token(conn),
         {user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
      conn
      |> assign(:current_scope, Scope.for_user(user))
      |> maybe_reissue_user_session_token(user, token_inserted_at)
    else
      nil -> assign(conn, :current_scope, Scope.for_user(nil))
    end
  end

  defp ensure_user_token(conn) do
    if token = get_session(conn, :user_token) do
      {token, conn}
    else
      conn = fetch_cookies(conn, signed: [@remember_me_cookie])

      if token = conn.cookies[@remember_me_cookie] do
        {token, conn |> put_token_in_session(token) |> put_session(:user_remember_me, true)}
      else
        nil
      end
    end
  end

  # Reissue the session token if it is older than the configured reissue age.
  defp maybe_reissue_user_session_token(conn, user, token_inserted_at) do
    token_age = DateTime.diff(DateTime.utc_now(:second), token_inserted_at, :day)

    if token_age >= @session_reissue_age_in_days do
      create_or_extend_session(conn, user, %{})
    else
      conn
    end
  end

  # This function is the one responsible for creating session tokens
  # and storing them safely in the session and cookies. It may be called
  # either when logging in, during sudo mode, or to renew a session which
  # will soon expire.
  #
  # When the session is created, rather than extended, the renew_session
  # function will clear the session to avoid fixation attacks. See the
  # renew_session function to customize this behaviour.
  defp create_or_extend_session(conn, user, params) do
    token = Accounts.generate_user_session_token(user)
    remember_me = get_session(conn, :user_remember_me)

    conn
    |> renew_session(user)
    |> put_token_in_session(token)
    |> maybe_write_remember_me_cookie(token, params, remember_me)
  end

  # Do not renew session if the user is already logged in
  # to prevent CSRF errors or data being lost in tabs that are still open
  defp renew_session(conn, user) when conn.assigns.current_scope.user.id == user.id do
    conn
  end

  # This function renews the session ID and erases the whole
  # session to avoid fixation attacks. If there is any data
  # in the session you may want to preserve after log in/log out,
  # you must explicitly fetch the session data before clearing
  # and then immediately set it after clearing, for example:
  #
  #     defp renew_session(conn, _user) do
  #       delete_csrf_token()
  #       preferred_locale = get_session(conn, :preferred_locale)
  #
  #       conn
  #       |> configure_session(renew: true)
  #       |> clear_session()
  #       |> put_session(:preferred_locale, preferred_locale)
  #     end
  #
  defp renew_session(conn, _user) do
    delete_csrf_token()

    conn
    |> configure_session(renew: true)
    |> clear_session()
  end

  defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}, _),
    do: write_remember_me_cookie(conn, token)

  defp maybe_write_remember_me_cookie(conn, token, _params, true),
    do: write_remember_me_cookie(conn, token)

  defp maybe_write_remember_me_cookie(conn, _token, _params, _), do: conn

  defp write_remember_me_cookie(conn, token) do
    conn
    |> put_session(:user_remember_me, true)
    |> put_resp_cookie(@remember_me_cookie, token, @remember_me_options)
  end

  defp put_token_in_session(conn, token) do
    put_session(conn, :user_token, token)
  end

  def require_sudo_mode(conn, _opts) do
    if Accounts.sudo_mode?(conn.assigns.current_scope.user, -10) do
      conn
    else
      conn
      |> put_flash(:error, "You must re-authenticate to access this page.")
      |> maybe_store_return_to()
      |> redirect(to: ~p"/users/log-in")
      |> halt()
    end
  end

  def redirect_if_user_is_authenticated(conn, _opts) do
    if conn.assigns.current_scope do
      conn
      |> redirect(to: signed_in_path(conn))
      |> halt()
    else
      conn
    end
  end

  defp signed_in_path(_conn), do: ~p"/"

  def require_authenticated_user(conn, _opts) do
    if conn.assigns.current_scope && conn.assigns.current_scope.user do
      conn
    else
      conn
      |> put_flash(:error, "You must log in to access this page.")
      |> maybe_store_return_to()
      |> redirect(to: ~p"/users/log-in")
      |> halt()
    end
  end

  defp maybe_store_return_to(%{method: "GET"} = conn) do
    put_session(conn, :user_return_to, current_path(conn))
  end

  defp maybe_store_return_to(conn), do: conn
end

lib/hello_web/router.ex: o router com as rotas que o phx.gen.auth acrescentou e a caixa de entrada do Swoosh em /dev/mailbox.

defmodule HelloWeb.Router do
  use HelloWeb, :router

  import HelloWeb.UserAuth

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {HelloWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug :fetch_current_scope_for_user
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :home
  end

  # Em desenvolvimento: a caixa de entrada do Swoosh.
  scope "/dev" do
    pipe_through :browser

    forward "/mailbox", Plug.Swoosh.MailboxPreview
  end

  ## Authentication routes

  scope "/", HelloWeb do
    pipe_through [:browser, :redirect_if_user_is_authenticated]

    get "/users/register", UserRegistrationController, :new
    post "/users/register", UserRegistrationController, :create
  end

  scope "/", HelloWeb do
    pipe_through [:browser, :require_authenticated_user]

    get "/users/settings", UserSettingsController, :edit
    put "/users/settings", UserSettingsController, :update
    get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
  end

  scope "/", HelloWeb do
    pipe_through [:browser]

    get "/users/log-in", UserSessionController, :new
    get "/users/log-in/:token", UserSessionController, :confirm
    post "/users/log-in", UserSessionController, :create
    delete "/users/log-out", UserSessionController, :delete
  end
end
defmodule HelloWeb.UserRegistrationController do
  use HelloWeb, :controller

  alias Hello.Accounts
  alias Hello.Accounts.User

  def new(conn, _params) do
    changeset = Accounts.change_user_email(%User{})
    render(conn, :new, changeset: changeset)
  end

  def create(conn, %{"user" => user_params}) do
    case Accounts.register_user(user_params) do
      {:ok, user} ->
        {:ok, _} =
          Accounts.deliver_login_instructions(
            user,
            &url(~p"/users/log-in/#{&1}")
          )

        conn
        |> put_flash(
          :info,
          "An email was sent to #{user.email}, please access it to confirm your account."
        )
        |> redirect(to: ~p"/users/log-in")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :new, changeset: changeset)
    end
  end
end
defmodule HelloWeb.UserRegistrationHTML do
  use HelloWeb, :html

  # user_registration_html/new.html.heex
  def new(assigns) do
    ~H"""
      <Layouts.app flash={@flash} current_scope={@current_scope}>
        <div class="mx-auto max-w-sm">
          <div class="text-center">
            <.header>
              Register for an account
              <:subtitle>
                Already registered?
                <.link navigate={~p"/users/log-in"} class="font-semibold text-brand hover:underline">
                  Log in
                </.link>
                to your account now.
              </:subtitle>
            </.header>
          </div>

          <.form :let={f} for={@changeset} action={~p"/users/register"}>
            <.input
              field={f[:email]}
              type="email"
              label="Email"
              autocomplete="username"
              spellcheck="false"
              required
              phx-mounted={JS.focus()}
            />

            <.button phx-disable-with="Creating account..." class="btn btn-primary w-full">
              Create an account
            </.button>
          </.form>
        </div>
      </Layouts.app>
    """
  end
end
defmodule HelloWeb.UserSessionController do
  use HelloWeb, :controller

  alias Hello.Accounts
  alias HelloWeb.UserAuth

  def new(conn, _params) do
    email = get_in(conn.assigns, [:current_scope, Access.key(:user), Access.key(:email)])
    form = Phoenix.Component.to_form(%{"email" => email}, as: "user")

    render(conn, :new, form: form)
  end

  # magic link login
  def create(conn, %{"user" => %{"token" => token} = user_params} = params) do
    info =
      case params do
        %{"_action" => "confirmed"} -> "User confirmed successfully."
        _ -> "Welcome back!"
      end

    case Accounts.login_user_by_magic_link(token) do
      {:ok, {user, _expired_tokens}} ->
        conn
        |> put_flash(:info, info)
        |> UserAuth.log_in_user(user, user_params)

      {:error, :not_found} ->
        conn
        |> put_flash(:error, "The link is invalid or it has expired.")
        |> render(:new, form: Phoenix.Component.to_form(%{}, as: "user"))
    end
  end

  # email + password login
  def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do
    if user = Accounts.get_user_by_email_and_password(email, password) do
      conn
      |> put_flash(:info, "Welcome back!")
      |> UserAuth.log_in_user(user, user_params)
    else
      form = Phoenix.Component.to_form(user_params, as: "user")

      # In order to prevent user enumeration attacks, don't disclose whether the email is registered.
      conn
      |> put_flash(:error, "Invalid email or password")
      |> render(:new, form: form)
    end
  end

  # magic link request
  def create(conn, %{"user" => %{"email" => email}}) do
    if user = Accounts.get_user_by_email(email) do
      Accounts.deliver_login_instructions(
        user,
        &url(~p"/users/log-in/#{&1}")
      )
    end

    info =
      "If your email is in our system, you will receive instructions for logging in shortly."

    conn
    |> put_flash(:info, info)
    |> redirect(to: ~p"/users/log-in")
  end

  def confirm(conn, %{"token" => token}) do
    if user = Accounts.get_user_by_magic_link_token(token) do
      form = Phoenix.Component.to_form(%{"token" => token}, as: "user")

      conn
      |> assign(:user, user)
      |> assign(:form, form)
      |> render(:confirm)
    else
      conn
      |> put_flash(:error, "Magic link is invalid or it has expired.")
      |> redirect(to: ~p"/users/log-in")
    end
  end

  def delete(conn, _params) do
    conn
    |> put_flash(:info, "Logged out successfully.")
    |> UserAuth.log_out_user()
  end
end
defmodule HelloWeb.UserSessionHTML do
  use HelloWeb, :html

  # user_session_html/confirm.html.heex
  def confirm(assigns) do
    ~H"""
      <Layouts.app flash={@flash} current_scope={@current_scope}>
        <div class="mx-auto max-w-sm">
          <div class="text-center">
            <.header>Welcome {@user.email}</.header>
          </div>

          <.form
            :if={!@user.confirmed_at}
            for={@form}
            id="confirmation_form"
            action={~p"/users/log-in?_action=confirmed"}
            phx-mounted={JS.focus_first()}
          >
            <input type="hidden" name={@form[:token].name} value={@form[:token].value} />
            <.button
              name={@form[:remember_me].name}
              value="true"
              phx-disable-with="Confirming..."
              class="btn btn-primary w-full"
            >
              Confirm and stay logged in
            </.button>
            <.button phx-disable-with="Confirming..." class="btn btn-primary btn-soft w-full mt-2">
              Confirm and log in only this time
            </.button>
          </.form>

          <.form
            :if={@user.confirmed_at}
            for={@form}
            id="login_form"
            action={~p"/users/log-in"}
            phx-mounted={JS.focus_first()}
          >
            <input type="hidden" name={@form[:token].name} value={@form[:token].value} />
            <%= if @current_scope do %>
              <.button variant="primary" phx-disable-with="Logging in..." class="btn btn-primary w-full">
                Log in
              </.button>
            <% else %>
              <.button
                name={@form[:remember_me].name}
                value="true"
                phx-disable-with="Logging in..."
                class="btn btn-primary w-full"
              >
                Keep me logged in on this device
              </.button>
              <.button phx-disable-with="Logging in..." class="btn btn-primary btn-soft w-full mt-2">
                Log me in only this time
              </.button>
            <% end %>
          </.form>

          <p :if={!@user.confirmed_at} class="alert alert-outline mt-8">
            Tip: If you prefer passwords, you can enable them in the user settings.
          </p>
        </div>
      </Layouts.app>
    """
  end

  # user_session_html/new.html.heex
  def new(assigns) do
    ~H"""
      <Layouts.app flash={@flash} current_scope={@current_scope}>
        <div class="mx-auto max-w-sm space-y-4">
          <div class="text-center">
            <.header>
              <p>Log in</p>
              <:subtitle>
                <%= if @current_scope do %>
                  You need to reauthenticate to perform sensitive actions on your account.
                <% else %>
                  Don't have an account? <.link
                    navigate={~p"/users/register"}
                    class="font-semibold text-brand hover:underline"
                    phx-no-format
                  >Sign up</.link> for an account now.
                <% end %>
              </:subtitle>
            </.header>
          </div>

          <div :if={local_mail_adapter?()} class="alert alert-info">
            <.icon name="hero-information-circle" class="size-6 shrink-0" />
            <div>
              <p>You are running the local mail adapter.</p>
              <p>
                To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page</.link>.
              </p>
            </div>
          </div>

          <.form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/users/log-in"}>
            <.input
              readonly={!!@current_scope}
              field={f[:email]}
              type="email"
              label="Email"
              autocomplete="username"
              spellcheck="false"
              required
              phx-mounted={JS.focus()}
            />
            <.button class="btn btn-primary w-full">
              Log in with email <span aria-hidden="true">→</span>
            </.button>
          </.form>

          <div class="divider">or</div>

          <.form :let={f} for={@form} as={:user} id="login_form_password" action={~p"/users/log-in"}>
            <.input
              readonly={!!@current_scope}
              field={f[:email]}
              type="email"
              label="Email"
              autocomplete="username"
              spellcheck="false"
              required
            />
            <.input
              field={f[:password]}
              type="password"
              label="Password"
              autocomplete="current-password"
              spellcheck="false"
            />
            <.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true">
              Log in and stay logged in <span aria-hidden="true">→</span>
            </.button>
            <.button class="btn btn-primary btn-soft w-full mt-2">
              Log in only this time
            </.button>
          </.form>
        </div>
      </Layouts.app>
    """
  end

  defp local_mail_adapter? do
    Application.get_env(:hello, Hello.Mailer)[:adapter] == Swoosh.Adapters.Local
  end
end
defmodule HelloWeb.UserSettingsController do
  use HelloWeb, :controller

  alias Hello.Accounts
  alias HelloWeb.UserAuth

  import HelloWeb.UserAuth, only: [require_sudo_mode: 2]

  plug :require_sudo_mode
  plug :assign_email_and_password_changesets

  def edit(conn, _params) do
    render(conn, :edit)
  end

  def update(conn, %{"action" => "update_email"} = params) do
    %{"user" => user_params} = params
    user = conn.assigns.current_scope.user

    case Accounts.change_user_email(user, user_params) do
      %{valid?: true} = changeset ->
        Accounts.deliver_user_update_email_instructions(
          Ecto.Changeset.apply_action!(changeset, :insert),
          user.email,
          &url(~p"/users/settings/confirm-email/#{&1}")
        )

        conn
        |> put_flash(
          :info,
          "A link to confirm your email change has been sent to the new address."
        )
        |> redirect(to: ~p"/users/settings")

      changeset ->
        render(conn, :edit, email_changeset: %{changeset | action: :insert})
    end
  end

  def update(conn, %{"action" => "update_password"} = params) do
    %{"user" => user_params} = params
    user = conn.assigns.current_scope.user

    case Accounts.update_user_password(user, user_params) do
      {:ok, {user, _}} ->
        conn
        |> put_flash(:info, "Password updated successfully.")
        |> put_session(:user_return_to, ~p"/users/settings")
        |> UserAuth.log_in_user(user)

      {:error, changeset} ->
        render(conn, :edit, password_changeset: changeset)
    end
  end

  def confirm_email(conn, %{"token" => token}) do
    case Accounts.update_user_email(conn.assigns.current_scope.user, token) do
      {:ok, _user} ->
        conn
        |> put_flash(:info, "Email changed successfully.")
        |> redirect(to: ~p"/users/settings")

      {:error, _} ->
        conn
        |> put_flash(:error, "Email change link is invalid or it has expired.")
        |> redirect(to: ~p"/users/settings")
    end
  end

  defp assign_email_and_password_changesets(conn, _opts) do
    user = conn.assigns.current_scope.user

    conn
    |> assign(:email_changeset, Accounts.change_user_email(user))
    |> assign(:password_changeset, Accounts.change_user_password(user))
  end
end
defmodule HelloWeb.UserSettingsHTML do
  use HelloWeb, :html

  # user_settings_html/edit.html.heex
  def edit(assigns) do
    ~H"""
      <Layouts.app flash={@flash} current_scope={@current_scope}>
        <div class="text-center">
          <.header>
            Account Settings
            <:subtitle>Manage your account email address and password settings</:subtitle>
          </.header>
        </div>

        <.form :let={f} for={@email_changeset} action={~p"/users/settings"} id="update_email">
          <input type="hidden" name="action" value="update_email" />

          <.input
            field={f[:email]}
            type="email"
            label="Email"
            autocomplete="username"
            spellcheck="false"
            required
          />

          <.button variant="primary" phx-disable-with="Changing...">Change Email</.button>
        </.form>

        <div class="divider" />

        <.form :let={f} for={@password_changeset} action={~p"/users/settings"} id="update_password">
          <input type="hidden" name="action" value="update_password" />

          <.input
            field={f[:password]}
            type="password"
            label="New password"
            autocomplete="new-password"
            spellcheck="false"
            required
          />
          <.input
            field={f[:password_confirmation]}
            type="password"
            label="Confirm new password"
            autocomplete="new-password"
            spellcheck="false"
            required
          />
          <.button variant="primary" phx-disable-with="Changing...">
            Save Password
          </.button>
        </.form>
      </Layouts.app>
    """
  end
end

priv/repo/seeds.exs: duas contas confirmadas, com senha. No notebook 13, a confirmação veio pelo link do e-mail; aqui o código confirma direto, para ir ao assunto.

alias Hello.{Accounts, Repo}
alias Hello.Accounts.User

for email <- ["ana@example.com", "bia@example.com"] do
  {:ok, user} = Accounts.register_user(%{email: email})
  user = user |> User.confirm_changeset() |> Repo.update!()
  {:ok, {_user, _tokens}} = Accounts.update_user_password(user, %{password: "senha da trilha"})
end

Hello.Repo.all(User) |> Enum.map(&{&1.id, &1.email, &1.confirmed_at != nil})

Entre como Ana e como Bia. Daqui em diante, os cookies de cada uma guardam o login.

Trilha.entrar("ana", "ana@example.com")
Trilha.entrar("bia", "bia@example.com")

O que é um escopo

Na documentação: Scopes

Um escopo é uma estrutura de dados que guarda informações sobre a requisição ou a sessão atual: o usuário logado, a organização ou empresa desse usuário, permissões, e assim por diante. Pense nele como um recipiente com o que a maioria das páginas da aplicação precisa saber. Ele pode guardar também metadados da requisição, como o endereço IP.

Escopos têm um papel importante na segurança. A OWASP põe o "Broken access control" (controle de acesso quebrado) em primeiro lugar na sua lista dos dez maiores riscos.

Trilha.pergunta(:owasp)

A maior parte dos dados de uma aplicação é privada de um usuário, de uma equipe ou de uma organização, e cada operação de banco precisa estar restrita a eles. Os geradores do Phoenix (mix phx.gen.html, mix phx.gen.json, mix phx.gen.live) usam os seus escopos automaticamente.

Escopos são flexíveis: uma aplicação pode ter mais de um, e você escolhe qual usar a cada vez que roda um gerador. O mix phx.gen.auth gera um para você, mas você também pode definir os seus. O guia segue esta ordem, e o notebook também:

  • o escopo que o mix phx.gen.auth gera;
  • como os geradores, como mix phx.gen.context, se apoiam nos escopos para garantir a segurança;
  • como definir um escopo do zero, com todas as opções;
  • como ampliar o escopo gerado com outras informações, como a organização.

O escopo do phx.gen.auth

Na documentação: phx.gen.auth

O mix phx.gen.auth gera um escopo padrão, que liga os recursos gerados depois ao usuário autenticado. O código do escopo é o mesmo nas variantes --live e --no-live. Ele está na célula lib/hello/accounts/scope.ex, mais acima: uma struct com um único campo, user, e uma função for_user/1 que, dado um %User{}, devolve um %Scope{} com esse usuário, e dado nil, devolve nil.

O escopo é buscado a cada requisição pelo plug fetch_current_scope_for_user, que o gerador injeta no pipeline :browser do router. O guia mostra uma versão curta desse plug:

# user_auth.ex, como o guia mostra
def fetch_current_scope_for_user(conn, _opts) do
  {user_token, conn} = ensure_user_token(conn)
  user = user_token && Accounts.get_user_by_session_token(user_token)
  assign(conn, :current_scope, Scope.for_user(user))
end

O código que o Phoenix 1.8.16 gera, na célula do HelloWeb.UserAuth, é um pouco diferente:

# user_auth.ex, como o 1.8.16 gera
def fetch_current_scope_for_user(conn, _opts) do
  with {token, conn} <- ensure_user_token(conn),
       {user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
    conn
    |> assign(:current_scope, Scope.for_user(user))
    |> maybe_reissue_user_session_token(user, token_inserted_at)
  else
    nil -> assign(conn, :current_scope, Scope.for_user(nil))
  end
end

get_user_by_session_token/1 passou a devolver uma tupla {user, token_inserted_at}, e o plug usa a data do token para emitir um token novo quando o atual fica velho (maybe_reissue_user_session_token/3). Copiado do guia, user receberia a tupla inteira, e Scope.for_user/1 não casaria. A ideia, porém, é a mesma: no fim, conn.assigns.current_scope tem o escopo do usuário, ou nil.

Trilha.pergunta(:anonimo)

Trilha.escopos/2 faz uma requisição como o visitante e mostra o que o pipeline deixou nos assigns:

Trilha.escopos("ana")
Trilha.escopos("carla")

A Ana tem um %Hello.Accounts.Scope{} com o usuário dela; a Carla, que nunca fez login, tem nil. Para LiveViews, a variante --live do gerador traz um hook :mount_current_scope que faz o mesmo no mount de cada LiveView. Ele aparece no módulo de LiveView.

Os geradores e os escopos

Na documentação: Integration of scopes in the Phoenix generators

Se a configuração da aplicação define um escopo padrão, os geradores constroem recursos com escopo. O mix phx.gen.auth marca o seu escopo como padrão, se ainda não houver outro, injetando este bloco em config/config.exs:

config :hello, :scopes,
  user: [
    default: true,
    module: Hello.Accounts.Scope,
    assign_key: :current_scope,
    access_path: [:user, :id],
    schema_key: :user_id,
    schema_type: :id,
    schema_table: :users,
    test_data_fixture: Hello.AccountsFixtures,
    test_setup_helper: :register_and_log_in_user
  ]

A próxima seção explica cada opção. O guia gera um blog com mix phx.gen.live Blog Post posts title:string body:text. O equivalente com controllers, no mesmo projeto com phx.gen.auth, é:

$ mix phx.gen.html Blog Post posts title:string body:text
* creating lib/hello_web/controllers/post_controller.ex
* creating lib/hello_web/controllers/post_html/edit.html.heex
* creating lib/hello_web/controllers/post_html/index.html.heex
* creating lib/hello_web/controllers/post_html/new.html.heex
* creating lib/hello_web/controllers/post_html/show.html.heex
* creating lib/hello_web/controllers/post_html/post_form.html.heex
* creating lib/hello_web/controllers/post_html.ex
* creating test/hello_web/controllers/post_controller_test.exs
* creating lib/hello/blog/post.ex
* creating priv/repo/migrations/20250611100000_create_posts.exs
* creating lib/hello/blog.ex
* injecting lib/hello/blog.ex
* creating test/hello/blog_test.exs
* injecting test/hello/blog_test.exs
* creating test/support/fixtures/blog_fixtures.ex
* injecting test/support/fixtures/blog_fixtures.ex

Add the resource to your browser scope in lib/hello_web/router.ex:

    resources "/posts", PostController

Ensure the routes are defined in a block that sets the `:current_scope` assign.

Remember to update your repository by running migrations:

    $ mix ecto.migrate

A penúltima linha é nova: com escopo, as rotas precisam ficar num bloco do router que atribua :current_scope. Comece pelo banco e pelo contexto.

priv/repo/migrations/20250611100000_create_posts.exs: além dos campos pedidos, a coluna user_id, com chave estrangeira para users e um índice. Nenhum campo user_id foi pedido no comando; ele vem do escopo (schema_key, schema_table).

defmodule Hello.Repo.Migrations.CreatePosts do
  use Ecto.Migration

  def change do
    create table(:posts) do
      add :title, :string
      add :body, :text
      add :user_id, references(:users, on_delete: :delete_all)

      timestamps(type: :utc_datetime)
    end

    create index(:posts, [:user_id])
  end
end

Trilha.migracao(20250611100000, Hello.Repo.Migrations.CreatePosts)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)

lib/hello/blog/post.ex: o changeset/3 recebe o escopo como terceiro argumento e põe o user_id com put_change/3. O user_id não está na lista do cast/3: ele nunca vem dos parâmetros do formulário.

defmodule Hello.Blog.Post do
  use Ecto.Schema
  import Ecto.Changeset

  schema "posts" do
    field :title, :string
    field :body, :string
    field :user_id, :id

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(post, attrs, user_scope) do
    post
    |> cast(attrs, [:title, :body])
    |> validate_required([:title, :body])
    |> put_change(:user_id, user_scope.user.id)
  end
end

lib/hello/blog.ex: cada função pública recebe o escopo como primeiro argumento. Leia três detalhes:

  • list_posts/1 e get_post!/2 filtram por user_id: scope.user.id;
  • update_post/3, delete_post/2 e change_post/3 começam com true = post.user_id == scope.user.id;
  • subscribe_posts/1 e broadcast_post/2 usam um tópico do PubSub com o id do usuário: "user:#{key}:posts".
defmodule Hello.Blog do
  @moduledoc """
  The Blog context.
  """

  import Ecto.Query, warn: false
  alias Hello.Repo

  alias Hello.Blog.Post
  alias Hello.Accounts.Scope

  def subscribe_posts(%Scope{} = scope) do
    key = scope.user.id

    Phoenix.PubSub.subscribe(Hello.PubSub, "user:#{key}:posts")
  end

  defp broadcast_post(%Scope{} = scope, message) do
    key = scope.user.id

    Phoenix.PubSub.broadcast(Hello.PubSub, "user:#{key}:posts", message)
  end

  def list_posts(%Scope{} = scope) do
    Repo.all_by(Post, user_id: scope.user.id)
  end

  def get_post!(%Scope{} = scope, id) do
    Repo.get_by!(Post, id: id, user_id: scope.user.id)
  end

  def create_post(%Scope{} = scope, attrs) do
    with {:ok, post = %Post{}} <-
           %Post{}
           |> Post.changeset(attrs, scope)
           |> Repo.insert() do
      broadcast_post(scope, {:created, post})
      {:ok, post}
    end
  end

  def update_post(%Scope{} = scope, %Post{} = post, attrs) do
    true = post.user_id == scope.user.id

    with {:ok, post = %Post{}} <-
           post
           |> Post.changeset(attrs, scope)
           |> Repo.update() do
      broadcast_post(scope, {:updated, post})
      {:ok, post}
    end
  end

  def delete_post(%Scope{} = scope, %Post{} = post) do
    true = post.user_id == scope.user.id

    with {:ok, post = %Post{}} <-
           Repo.delete(post) do
      broadcast_post(scope, {:deleted, post})
      {:ok, post}
    end
  end

  def change_post(%Scope{} = scope, %Post{} = post, attrs \\ %{}) do
    true = post.user_id == scope.user.id

    Post.changeset(post, attrs, scope)
  end
end

O guia mostra o list_posts/1 escrito com from post in Post, where: post.user_id == ^scope.user.id. O 1.8.16 gera Repo.all_by(Post, user_id: scope.user.id), uma função do Ecto 3.13 que faz a mesma consulta.

Sua vez: as rotas dos posts

O gerador pediu resources "/posts", PostController "num bloco que atribua :current_scope". Todo bloco que passa pelo :browser atribui :current_scope, mas sem login o valor é nil. Escolha o bloco certo na célula abaixo, acrescente a rota e avalie a célula.

Se você puser a rota no bloco público, a conferência logo depois do controller vai mostrar o problema: Blog.list_posts(nil) não casa com %Scope{}. Até a rota existir, o controller e os templates compilam com avisos no route path for HelloWeb.Router matches "/posts", as requisições a /posts mais abaixo mostram NoRouteError, e a célula que busca o post 1 direto no contexto levanta Ecto.NoResultsError.

# Sua vez: a rota dos posts. Acrescente resources "/posts", PostController
# ao bloco certo e avalie a célula.
defmodule HelloWeb.Router do
  use HelloWeb, :router

  import HelloWeb.UserAuth

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {HelloWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug :fetch_current_scope_for_user
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :home
  end

  # Em desenvolvimento: a caixa de entrada do Swoosh.
  scope "/dev" do
    pipe_through :browser

    forward "/mailbox", Plug.Swoosh.MailboxPreview
  end

  ## Authentication routes

  scope "/", HelloWeb do
    pipe_through [:browser, :redirect_if_user_is_authenticated]

    get "/users/register", UserRegistrationController, :new
    post "/users/register", UserRegistrationController, :create
  end

  scope "/", HelloWeb do
    pipe_through [:browser, :require_authenticated_user]

    get "/users/settings", UserSettingsController, :edit
    put "/users/settings", UserSettingsController, :update
    get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
  end

  scope "/", HelloWeb do
    pipe_through [:browser]

    get "/users/log-in", UserSessionController, :new
    get "/users/log-in/:token", UserSessionController, :confirm
    post "/users/log-in", UserSessionController, :create
    delete "/users/log-out", UserSessionController, :delete
  end
end

lib/hello_web/controllers/post_controller.ex: o controller passa conn.assigns.current_scope a cada função do contexto. Em new/2, o post vazio já nasce com o user_id do escopo, para que change_post/3 passe pela checagem do dono.

defmodule HelloWeb.PostController do
  use HelloWeb, :controller

  alias Hello.Blog
  alias Hello.Blog.Post

  def index(conn, _params) do
    posts = Blog.list_posts(conn.assigns.current_scope)
    render(conn, :index, posts: posts)
  end

  def new(conn, _params) do
    changeset =
      Blog.change_post(conn.assigns.current_scope, %Post{
        user_id: conn.assigns.current_scope.user.id
      })

    render(conn, :new, changeset: changeset)
  end

  def create(conn, %{"post" => post_params}) do
    case Blog.create_post(conn.assigns.current_scope, post_params) do
      {:ok, post} ->
        conn
        |> put_flash(:info, "Post created successfully.")
        |> redirect(to: ~p"/posts/#{post}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :new, changeset: changeset)
    end
  end

  def show(conn, %{"id" => id}) do
    post = Blog.get_post!(conn.assigns.current_scope, id)
    render(conn, :show, post: post)
  end

  def edit(conn, %{"id" => id}) do
    post = Blog.get_post!(conn.assigns.current_scope, id)
    changeset = Blog.change_post(conn.assigns.current_scope, post)
    render(conn, :edit, post: post, changeset: changeset)
  end

  def update(conn, %{"id" => id, "post" => post_params}) do
    post = Blog.get_post!(conn.assigns.current_scope, id)

    case Blog.update_post(conn.assigns.current_scope, post, post_params) do
      {:ok, post} ->
        conn
        |> put_flash(:info, "Post updated successfully.")
        |> redirect(to: ~p"/posts/#{post}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :edit, post: post, changeset: changeset)
    end
  end

  def delete(conn, %{"id" => id}) do
    post = Blog.get_post!(conn.assigns.current_scope, id)
    {:ok, _post} = Blog.delete_post(conn.assigns.current_scope, post)

    conn
    |> put_flash(:info, "Post deleted successfully.")
    |> redirect(to: ~p"/posts")
  end
end

lib/hello_web/controllers/post_html.ex: os templates, como funções. A diferença para os templates do notebook 08 é o current_scope={@current_scope} passado ao layout.

defmodule HelloWeb.PostHTML do
  use HelloWeb, :html

  # post_html/edit.html.heex
  def edit(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope}>
      <.header>
        Edit Post {@post.id}
        <:subtitle>Use this form to manage post records in your database.</:subtitle>
      </.header>

      <.post_form changeset={@changeset} action={~p"/posts/#{@post}"} return_to={~p"/posts"} />
    </Layouts.app>
    """
  end

  # post_html/index.html.heex
  def index(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope}>
      <.header>
        Listing Posts
        <:actions>
          <.button href={~p"/posts/new"}>
            <.icon name="hero-plus" /> New Post
          </.button>
        </:actions>
      </.header>

      <.table id="posts" rows={@posts} row_click={&JS.navigate(~p"/posts/#{&1}")}>
        <:col :let={post} label="Title">{post.title}</:col>
        <:col :let={post} label="Body">{post.body}</:col>
        <:action :let={post}>
          <div class="sr-only">
            <.link navigate={~p"/posts/#{post}"}>Show</.link>
          </div>
          <.link navigate={~p"/posts/#{post}/edit"}>Edit</.link>
        </:action>
        <:action :let={post}>
          <.link href={~p"/posts/#{post}"} method="delete" data-confirm="Are you sure?">
            Delete
          </.link>
        </:action>
      </.table>
    </Layouts.app>
    """
  end

  # post_html/new.html.heex
  def new(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope}>
      <.header>
        New Post
        <:subtitle>Use this form to manage post records in your database.</:subtitle>
      </.header>

      <.post_form changeset={@changeset} action={~p"/posts"} return_to={~p"/posts"} />
    </Layouts.app>
    """
  end

  # post_html/show.html.heex
  def show(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope}>
      <.header>
        Post {@post.id}
        <:subtitle>This is a post record from your database.</:subtitle>
        <:actions>
          <.button navigate={~p"/posts"}>
            <.icon name="hero-arrow-left" />
          </.button>
          <.button variant="primary" navigate={~p"/posts/#{@post}/edit?return_to=show"}>
            <.icon name="hero-pencil-square" /> Edit post
          </.button>
        </:actions>
      </.header>

      <.list>
        <:item title="Title">{@post.title}</:item>
        <:item title="Body">{@post.body}</:item>
      </.list>
    </Layouts.app>
    """
  end

  @doc """
  Renders a post form.

  The form is defined in the template at
  post_html/post_form.html.heex
  """
  attr :changeset, Ecto.Changeset, required: true
  attr :action, :string, required: true
  attr :return_to, :string, default: nil

  # post_html/post_form.html.heex
  def post_form(assigns) do
    ~H"""
    <.form :let={f} for={@changeset} action={@action}>
      <.input field={f[:title]} type="text" label="Title" />
      <.input field={f[:body]} type="textarea" label="Body" />
      <footer>
        <.button variant="primary">Save Post</.button>
        <.button :if={@return_to} href={@return_to}>Cancel</.button>
      </footer>
    </.form>
    """
  end
end
Trilha.conferir(:rotas_posts)
Trilha.solucao(:rotas_posts)

Com a rota no lugar, a Ana escreve um post:

Trilha.navegar("ana", :post, "/posts", %{"post" => %{"title" => "Post da Ana", "body" => "Só a Ana vê este texto."}})
Trilha.pergunta(:post_alheio)

A Bia pede a lista e, depois, o post da Ana pelo id:

Trilha.navegar("bia", :get, "/posts")
Trilha.navegar("bia", :get, "/posts/1")

A lista da Bia está vazia, e o post 1 levanta Ecto.NoResultsError. Leia a consulta na mensagem: where: p0.id == ^"1" and p0.user_id == ^2. Com debug_errors: true, o notebook mostra a exceção; em produção, o phoenix_ecto a transforma numa resposta 404, como diz a última linha.

E um visitante sem login é mandado para /users/log-in, pelo plug require_authenticated_user:

Trilha.navegar("carla", :get, "/posts")
Trilha.pergunta(:dono)

A célula abaixo chama o contexto direto, como um outro módulo da aplicação faria, e tenta atualizar o post da Ana com o escopo da Bia:

alias Hello.Accounts
alias Hello.Accounts.Scope

escopo_ana = Scope.for_user(Accounts.get_user_by_email("ana@example.com"))
escopo_bia = Scope.for_user(Accounts.get_user_by_email("bia@example.com"))
post_da_ana = Hello.Blog.get_post!(escopo_ana, 1)

try do
  Hello.Blog.update_post(escopo_bia, post_da_ana, %{title: "Agora é meu"})
rescue
  erro -> erro
end

O resultado é %MatchError{term: false}: a afirmação true = post.user_id == scope.user.id falhou antes de qualquer consulta ao banco.

Trilha.pergunta(:pubsub)

A célula abaixo assina as notificações de posts com o escopo da Ana, faz a Bia e a Ana criarem um post cada uma e recolhe as mensagens que chegaram ao processo da célula:

escopo_ana = Hello.Accounts.Scope.for_user(Hello.Accounts.get_user_by_email("ana@example.com"))
:ok = Hello.Blog.subscribe_posts(escopo_ana)

Trilha.visitar("bia", :post, "/posts", %{"post" => %{"title" => "Post da Bia", "body" => "Escrito pela Bia."}})
Trilha.visitar("ana", :post, "/posts", %{"post" => %{"title" => "Segundo post da Ana", "body" => "Escrito pela Ana."}})

recebidas =
  Stream.repeatedly(fn ->
    receive do
      {evento, %Hello.Blog.Post{title: titulo}} -> {evento, titulo}
    after
      200 -> :fim
    end
  end)
  |> Enum.take_while(&(&1 != :fim))

Phoenix.PubSub.unsubscribe(Hello.PubSub, "user:#{escopo_ana.user.id}:posts")
recebidas

Só chegou {:created, "Segundo post da Ana"}. Como os controllers rodam no mesmo processo que faz a requisição no notebook, o próprio processo da célula recebe a mensagem. Num projeto, quem assina costuma ser um LiveView: a lista gerada por phx.gen.live assina no mount e recarrega quando chega {:created, _}, {:updated, _} ou {:deleted, _}.

Trilha.pergunta(:router_scope)

O guia faz essa observação: o scope do router e o current_scope têm propósitos parecidos, restringir o acesso a partes da aplicação, mas atuam em camadas diferentes. O primeiro decide quais plugs rodam para quais caminhos; o segundo vai até cada consulta ao banco.

Definindo escopos

Na documentação: Defining scopes

Os geradores descobrem os escopos pela configuração da aplicação, a mesma config :hello, :scopes que o phx.gen.auth injetou. No exemplo, o escopo se chama user e é o padrão (default: true), usado por mix phx.gen.schema, mix phx.gen.context, mix phx.gen.live, mix phx.gen.html e mix phx.gen.json quando você não passa --scope. Cada opção:

Opção O que define No escopo user
default se é o escopo padrão dos geradores; só um pode ser true
module o módulo da struct que as funções do contexto recebem como primeiro argumento Hello.Accounts.Scope
assign_key a chave do escopo nos assigns do conn (ou do socket, num LiveView) :current_scope
access_path o caminho, dentro da struct, até o campo que identifica o dono; vira scope.user.id no código gerado [:user, :id]
route_prefix (opcional) um prefixo para as rotas, como "/organizations/:org" não usa
route_access_path (opcional) o caminho até o valor que preenche o prefixo da rota; o padrão é access_path sem o último item não usa
schema_key a coluna que liga o recurso ao escopo :user_id
schema_type o tipo dessa coluna no schema :id
schema_migration_type (opcional) o tipo da coluna na migration não usa
schema_table a tabela para onde a chave estrangeira aponta; nil cria uma coluna comum, sem chave estrangeira :users
test_data_fixture o módulo de fixtures importado nos testes de contexto; precisa ter NOME_scope_fixture/0 Hello.AccountsFixtures
test_setup_helper a função de setup dos testes de controller e LiveView :register_and_log_in_user

Um escopo não precisa vir do phx.gen.auth. O guia define um do zero, em que cada sessão do navegador tem o seu escopo, sem login. Não é útil na maioria das aplicações (os dados somem quando a sessão acaba), mas mostra o mecanismo inteiro.

lib/hello/scope.ex: a struct do novo escopo, com um único campo, id.

defmodule Hello.Scope do
  defstruct id: nil

  def for_id(id) do
    %Hello.Scope{id: id}
  end
end

A configuração do guia é esta, com uma diferença deste notebook, explicada logo abaixo:

config :hello, :scopes,
  session: [
    default: true,
    module: Hello.Scope,
    assign_key: :session_scope,   # no guia: :current_scope
    access_path: [:id],
    schema_key: :session_id,
    schema_type: :id,
    schema_migration_type: :bigint,
    schema_table: nil,
    test_data_fixture: Hello.ScopeFixtures,
    test_setup_helper: :put_scope_in_session
  ]
Trilha.pergunta(:assign_key)

O guia gera mix phx.gen.html Post posts title:string. Este notebook já tem um Hello.Blog.Post na tabela posts, então o recurso de sessão aqui se chama Note. Com a configuração acima (o escopo user com default: false, o session com default: true):

$ mix phx.gen.html Notes Note notes title:string
* creating lib/hello_web/controllers/note_controller.ex
* creating lib/hello_web/controllers/note_html/edit.html.heex
* creating lib/hello_web/controllers/note_html/index.html.heex
* creating lib/hello_web/controllers/note_html/new.html.heex
* creating lib/hello_web/controllers/note_html/show.html.heex
* creating lib/hello_web/controllers/note_html/note_form.html.heex
* creating lib/hello_web/controllers/note_html.ex
* creating test/hello_web/controllers/note_controller_test.exs
* creating lib/hello/notes/note.ex
* creating priv/repo/migrations/20250611110000_create_notes.exs
* creating lib/hello/notes.ex
* injecting lib/hello/notes.ex
* creating test/hello/notes_test.exs
* injecting test/hello/notes_test.exs
* creating test/support/fixtures/notes_fixtures.ex
* injecting test/support/fixtures/notes_fixtures.ex

Add the resource to your browser scope in lib/hello_web/router.ex:

    resources "/notes", NoteController

Ensure the routes are defined in a block that sets the `:session_scope` assign.

Remember to update your repository by running migrations:

    $ mix ecto.migrate

Sua vez: um escopo por sessão

O guia acrescenta ao pipeline :browser um plug que dá a cada sessão um escopo: se a sessão já tem um :scope_id, usa-o; senão, cria um com System.unique_integer/0, guarda-o na sessão com put_session/3 e o atribui. Na célula abaixo, o pipeline já chama plug :assign_scope e o bloco público já tem resources "/notes", NoteController. Escreva o corpo de assign_scope/2, atribuindo o escopo em :session_scope, e avalie a célula.

Até o plug ficar pronto, as requisições a /notes mais abaixo levantam KeyError: o controller gerado lê conn.assigns.session_scope.

defmodule HelloWeb.Router do
  use HelloWeb, :router

  import HelloWeb.UserAuth

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {HelloWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug :fetch_current_scope_for_user
    plug :assign_scope
  end

  defp assign_scope(conn, _opts) do
    # Sua vez: o plug assign_scope.
    conn
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :home

    resources "/notes", NoteController
  end

  # Em desenvolvimento: a caixa de entrada do Swoosh.
  scope "/dev" do
    pipe_through :browser

    forward "/mailbox", Plug.Swoosh.MailboxPreview
  end

  ## Authentication routes

  scope "/", HelloWeb do
    pipe_through [:browser, :redirect_if_user_is_authenticated]

    get "/users/register", UserRegistrationController, :new
    post "/users/register", UserRegistrationController, :create
  end

  scope "/", HelloWeb do
    pipe_through [:browser, :require_authenticated_user]

    get "/users/settings", UserSettingsController, :edit
    put "/users/settings", UserSettingsController, :update
    get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email

    resources "/posts", PostController
  end

  scope "/", HelloWeb do
    pipe_through [:browser]

    get "/users/log-in", UserSessionController, :new
    get "/users/log-in/:token", UserSessionController, :confirm
    post "/users/log-in", UserSessionController, :create
    delete "/users/log-out", UserSessionController, :delete
  end
end

priv/repo/migrations/20250611110000_create_notes.exs: session_id é um bigint comum, sem references.

defmodule Hello.Repo.Migrations.CreateNotes do
  use Ecto.Migration

  def change do
    create table(:notes) do
      add :title, :string
      add :session_id, :bigint

      timestamps(type: :utc_datetime)
    end

    create index(:notes, [:session_id])
  end
end

Trilha.migracao(20250611110000, Hello.Repo.Migrations.CreateNotes)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
Trilha.pergunta(:tabela_nil)

lib/hello/notes/note.ex e lib/hello/notes.ex: o mesmo desenho do Blog, agora com scope.id (o access_path é [:id]) e tópicos "session:#{key}:notes".

defmodule Hello.Notes.Note do
  use Ecto.Schema
  import Ecto.Changeset

  schema "notes" do
    field :title, :string
    field :session_id, :id

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(note, attrs, session_scope) do
    note
    |> cast(attrs, [:title])
    |> validate_required([:title])
    |> put_change(:session_id, session_scope.id)
  end
end
defmodule Hello.Notes do
  @moduledoc """
  The Notes context.
  """

  import Ecto.Query, warn: false
  alias Hello.Repo

  alias Hello.Notes.Note
  alias Hello.Scope

  def subscribe_notes(%Scope{} = scope) do
    key = scope.id

    Phoenix.PubSub.subscribe(Hello.PubSub, "session:#{key}:notes")
  end

  defp broadcast_note(%Scope{} = scope, message) do
    key = scope.id

    Phoenix.PubSub.broadcast(Hello.PubSub, "session:#{key}:notes", message)
  end

  def list_notes(%Scope{} = scope) do
    Repo.all_by(Note, session_id: scope.id)
  end

  def get_note!(%Scope{} = scope, id) do
    Repo.get_by!(Note, id: id, session_id: scope.id)
  end

  def create_note(%Scope{} = scope, attrs) do
    with {:ok, note = %Note{}} <-
           %Note{}
           |> Note.changeset(attrs, scope)
           |> Repo.insert() do
      broadcast_note(scope, {:created, note})
      {:ok, note}
    end
  end

  def update_note(%Scope{} = scope, %Note{} = note, attrs) do
    true = note.session_id == scope.id

    with {:ok, note = %Note{}} <-
           note
           |> Note.changeset(attrs, scope)
           |> Repo.update() do
      broadcast_note(scope, {:updated, note})
      {:ok, note}
    end
  end

  def delete_note(%Scope{} = scope, %Note{} = note) do
    true = note.session_id == scope.id

    with {:ok, note = %Note{}} <-
           Repo.delete(note) do
      broadcast_note(scope, {:deleted, note})
      {:ok, note}
    end
  end

  def change_note(%Scope{} = scope, %Note{} = note, attrs \\ %{}) do
    true = note.session_id == scope.id

    Note.changeset(note, attrs, scope)
  end
end

lib/hello_web/components/layouts.ex: os templates gerados passam session_scope={@session_scope} ao Layouts.app, porque é a assign_key do escopo. O Layouts.app gerado só declara o atributo current_scope, e o compilador avisaria quatro vezes: undefined attribute "session_scope" for component HelloWeb.Layouts.app/1. A célula abaixo é o layout com uma linha a mais, attr :session_scope.

defmodule HelloWeb.Layouts do
  use HelloWeb, :html

  def root(assigns) do
    ~H"""
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <meta name="csrf-token" content={get_csrf_token()} />
        <title>Hello · Phoenix Framework</title>
        <link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
        <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4">
        </script>
        <script defer src={~p"/assets/phoenix_html.js"}>
        </script>
      </head>
      <body>
        <ul class="menu menu-horizontal w-full relative z-10 flex items-center gap-4 px-4 sm:px-6 lg:px-8 justify-end">
          <%= if @current_scope do %>
            <li>
              {@current_scope.user.email}
            </li>
            <li>
              <.link href={~p"/users/settings"}>Settings</.link>
            </li>
            <li>
              <.link href={~p"/users/log-out"} method="delete">Log out</.link>
            </li>
          <% else %>
            <li>
              <.link href={~p"/users/register"}>Register</.link>
            </li>
            <li>
              <.link href={~p"/users/log-in"}>Log in</.link>
            </li>
          <% end %>
        </ul>
        {@inner_content}
      </body>
    </html>
    """
  end

  attr :flash, :map, required: true, doc: "the map of flash messages"
  attr :current_scope, :map, default: nil, doc: "the current scope"
  attr :session_scope, :map, default: nil, doc: "the session scope"
  slot :inner_block, required: true

  def app(assigns) do
    ~H"""
    <header class="navbar px-4 sm:px-6 lg:px-8">
      <div class="flex-1">
        <a href="/" class="flex-1 flex w-fit items-center gap-2">
          <span class="text-sm font-semibold">Hello · Phoenix v{Application.spec(:phoenix, :vsn)}</span>
        </a>
      </div>
    </header>

    <main class="px-4 py-20 sm:px-6 lg:px-8">
      <div class="mx-auto max-w-2xl space-y-4">
        {render_slot(@inner_block)}
      </div>
    </main>

    <.flash_group flash={@flash} />
    """
  end

  attr :flash, :map, required: true, doc: "the map of flash messages"
  attr :id, :string, default: "flash-group", doc: "the optional id of flash container"

  def flash_group(assigns) do
    ~H"""
    <div id={@id} aria-live="polite">
      <.flash kind={:info} flash={@flash} />
      <.flash kind={:error} flash={@flash} />
    </div>
    """
  end
end

lib/hello_web/controllers/note_controller.ex e note_html.ex: o controller lê conn.assigns.session_scope em toda action.

defmodule HelloWeb.NoteController do
  use HelloWeb, :controller

  alias Hello.Notes
  alias Hello.Notes.Note

  def index(conn, _params) do
    notes = Notes.list_notes(conn.assigns.session_scope)
    render(conn, :index, notes: notes)
  end

  def new(conn, _params) do
    changeset =
      Notes.change_note(conn.assigns.session_scope, %Note{
        session_id: conn.assigns.session_scope.id
      })

    render(conn, :new, changeset: changeset)
  end

  def create(conn, %{"note" => note_params}) do
    case Notes.create_note(conn.assigns.session_scope, note_params) do
      {:ok, note} ->
        conn
        |> put_flash(:info, "Note created successfully.")
        |> redirect(to: ~p"/notes/#{note}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :new, changeset: changeset)
    end
  end

  def show(conn, %{"id" => id}) do
    note = Notes.get_note!(conn.assigns.session_scope, id)
    render(conn, :show, note: note)
  end

  def edit(conn, %{"id" => id}) do
    note = Notes.get_note!(conn.assigns.session_scope, id)
    changeset = Notes.change_note(conn.assigns.session_scope, note)
    render(conn, :edit, note: note, changeset: changeset)
  end

  def update(conn, %{"id" => id, "note" => note_params}) do
    note = Notes.get_note!(conn.assigns.session_scope, id)

    case Notes.update_note(conn.assigns.session_scope, note, note_params) do
      {:ok, note} ->
        conn
        |> put_flash(:info, "Note updated successfully.")
        |> redirect(to: ~p"/notes/#{note}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :edit, note: note, changeset: changeset)
    end
  end

  def delete(conn, %{"id" => id}) do
    note = Notes.get_note!(conn.assigns.session_scope, id)
    {:ok, _note} = Notes.delete_note(conn.assigns.session_scope, note)

    conn
    |> put_flash(:info, "Note deleted successfully.")
    |> redirect(to: ~p"/notes")
  end
end
defmodule HelloWeb.NoteHTML do
  use HelloWeb, :html

  # note_html/edit.html.heex
  def edit(assigns) do
    ~H"""
    <Layouts.app flash={@flash} session_scope={@session_scope}>
      <.header>
        Edit Note {@note.id}
        <:subtitle>Use this form to manage note records in your database.</:subtitle>
      </.header>

      <.note_form changeset={@changeset} action={~p"/notes/#{@note}"} return_to={~p"/notes"} />
    </Layouts.app>
    """
  end

  # note_html/index.html.heex
  def index(assigns) do
    ~H"""
    <Layouts.app flash={@flash} session_scope={@session_scope}>
      <.header>
        Listing Notes
        <:actions>
          <.button href={~p"/notes/new"}>
            <.icon name="hero-plus" /> New Note
          </.button>
        </:actions>
      </.header>

      <.table id="notes" rows={@notes} row_click={&JS.navigate(~p"/notes/#{&1}")}>
        <:col :let={note} label="Title">{note.title}</:col>
        <:action :let={note}>
          <div class="sr-only">
            <.link navigate={~p"/notes/#{note}"}>Show</.link>
          </div>
          <.link navigate={~p"/notes/#{note}/edit"}>Edit</.link>
        </:action>
        <:action :let={note}>
          <.link href={~p"/notes/#{note}"} method="delete" data-confirm="Are you sure?">
            Delete
          </.link>
        </:action>
      </.table>
    </Layouts.app>
    """
  end

  # note_html/new.html.heex
  def new(assigns) do
    ~H"""
    <Layouts.app flash={@flash} session_scope={@session_scope}>
      <.header>
        New Note
        <:subtitle>Use this form to manage note records in your database.</:subtitle>
      </.header>

      <.note_form changeset={@changeset} action={~p"/notes"} return_to={~p"/notes"} />
    </Layouts.app>
    """
  end

  # note_html/show.html.heex
  def show(assigns) do
    ~H"""
    <Layouts.app flash={@flash} session_scope={@session_scope}>
      <.header>
        Note {@note.id}
        <:subtitle>This is a note record from your database.</:subtitle>
        <:actions>
          <.button navigate={~p"/notes"}>
            <.icon name="hero-arrow-left" />
          </.button>
          <.button variant="primary" navigate={~p"/notes/#{@note}/edit?return_to=show"}>
            <.icon name="hero-pencil-square" /> Edit note
          </.button>
        </:actions>
      </.header>

      <.list>
        <:item title="Title">{@note.title}</:item>
      </.list>
    </Layouts.app>
    """
  end

  @doc """
  Renders a note form.

  The form is defined in the template at
  note_html/note_form.html.heex
  """
  attr :changeset, Ecto.Changeset, required: true
  attr :action, :string, required: true
  attr :return_to, :string, default: nil

  # note_html/note_form.html.heex
  def note_form(assigns) do
    ~H"""
    <.form :let={f} for={@changeset} action={@action}>
      <.input field={f[:title]} type="text" label="Title" />
      <footer>
        <.button variant="primary">Save Note</.button>
        <.button :if={@return_to} href={@return_to}>Cancel</.button>
      </footer>
    </.form>
    """
  end
end
Trilha.conferir(:assign_scope)
Trilha.solucao(:assign_scope)

A Carla e o Davi, sem login, usam as notas ao mesmo tempo:

Trilha.navegar("carla", :post, "/notes", %{"note" => %{"title" => "Nota da Carla"}})
Trilha.navegar("davi", :get, "/notes")
Trilha.navegar("davi", :get, "/notes/1")
Trilha.navegar("carla", :get, "/notes")

O Davi não vê a nota da Carla, e o link dela, copiado para o navegador do Davi, dá Ecto.NoResultsError: o mesmo 404 dos posts, agora sem nenhum usuário envolvido. Abra uma janela anônima do navegador em http://localhost:4010/notes e veja o mesmo efeito.

No banco, cada nota tem só o número da sessão:

Trilha.consultar("SELECT id, title, session_id FROM notes")

Os números vêm de System.unique_integer/0, que garante valores únicos só enquanto a máquina virtual está no ar (e podem ser negativos). Depois de reiniciar a aplicação, um número pode se repetir; é mais um motivo para o guia dizer que o exemplo serve para entender o mecanismo, não para produção.

Para os testes gerados, o guia define uma fixture e um setup no ConnCase. Um notebook não roda mix test, então eles ficam só como referência:

# test/support/fixtures/scope_fixtures.ex
defmodule Hello.ScopeFixtures do
  alias Hello.Scope

  def session_scope_fixture(id \\ System.unique_integer()) do
    %Scope{id: id}
  end
end

# test/support/conn_case.ex
def put_scope_in_session(%{conn: conn}) do
  id = System.unique_integer()
  scope = Hello.ScopeFixtures.session_scope_fixture(id)

  conn =
    conn
    |> Phoenix.ConnTest.init_test_session(%{})
    |> Plug.Conn.put_session(:scope_id, id)

  %{conn: conn, scope: scope}
end

Ampliando escopos

Na documentação: Augmenting scopes

Agora a aplicação ganha organizações: cada usuário pode ser membro de várias, e certos dados pertencem à organização, não a um usuário. Em vez de criar um segundo escopo, o guia amplia o escopo gerado, que passa a carregar o usuário e a organização atual.

O guia mostra o schema da organização com reticências. Aqui ele está completo, com a migration que cria organizations e a tabela de junção organizations_users.

priv/repo/migrations/20250612090000_create_organizations.exs

defmodule Hello.Repo.Migrations.CreateOrganizations do
  use Ecto.Migration

  def change do
    create table(:organizations) do
      add :name, :string, null: false
      add :slug, :string, null: false

      timestamps(type: :utc_datetime)
    end

    create unique_index(:organizations, [:slug])

    create table(:organizations_users, primary_key: false) do
      add :organization_id, references(:organizations, on_delete: :delete_all), null: false
      add :user_id, references(:users, on_delete: :delete_all), null: false
    end

    create unique_index(:organizations_users, [:organization_id, :user_id])
    create index(:organizations_users, [:user_id])
  end
end

Trilha.migracao(20250612090000, Hello.Repo.Migrations.CreateOrganizations)

lib/hello/accounts/organization.ex: @derive {Phoenix.Param, key: :slug} faz o ~p usar o slug, e não o id, quando recebe uma organização: ~p"/organizations/#{org}" vira /organizations/acme.

defmodule Hello.Accounts.Organization do
  use Ecto.Schema
  import Ecto.Changeset

  @derive {Phoenix.Param, key: :slug}
  schema "organizations" do
    field :name, :string
    field :slug, :string

    many_to_many :users, Hello.Accounts.User, join_through: "organizations_users"

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(organization, attrs) do
    organization
    |> cast(attrs, [:name, :slug])
    |> validate_required([:name, :slug])
    |> validate_format(:slug, ~r/^[a-z0-9-]+$/)
    |> unique_constraint(:slug)
  end
end

lib/hello/accounts/scope.ex: a struct ganha o campo organization e a função put_organization/2.

defmodule Hello.Accounts.Scope do
  @moduledoc """
  Defines the scope of the caller to be used throughout the app.
  """

  alias Hello.Accounts.User
  alias Hello.Accounts.Organization

  defstruct user: nil, organization: nil

  @doc """
  Creates a scope for the given user.

  Returns nil if no user is given.
  """
  def for_user(%User{} = user) do
    %__MODULE__{user: user}
  end

  def for_user(nil), do: nil

  def put_organization(%__MODULE__{} = scope, %Organization{} = organization) do
    %{scope | organization: organization}
  end
end

lib/hello/accounts.ex: o contexto ganha as funções das organizações. get_organization_by_slug!/2 é a que o plug do router vai chamar, e ela tem um defeito de propósito: devolve qualquer organização pelo slug, sem olhar para o escopo. Avalie a célula como está; o defeito aparece mais abaixo. As duas últimas funções, sem escopo, servem às funções auxiliares do fim do guia.

defmodule Hello.Accounts do
  import Ecto.Query, warn: false
  alias Hello.Repo

  alias Hello.Accounts.{User, UserToken, UserNotifier}

  ## Database getters

  def get_user_by_email(email) when is_binary(email) do
    Repo.get_by(User, email: email)
  end

  def get_user_by_email_and_password(email, password)
      when is_binary(email) and is_binary(password) do
    user = Repo.get_by(User, email: email)
    if User.valid_password?(user, password), do: user
  end

  def get_user!(id), do: Repo.get!(User, id)

  ## User registration

  def register_user(attrs) do
    %User{}
    |> User.email_changeset(attrs)
    |> Repo.insert()
  end

  ## Settings

  def sudo_mode?(user, minutes \\ -20)

  def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do
    DateTime.after?(ts, DateTime.utc_now() |> DateTime.add(minutes, :minute))
  end

  def sudo_mode?(_user, _minutes), do: false

  def change_user_email(user, attrs \\ %{}, opts \\ []) do
    User.email_changeset(user, attrs, opts)
  end

  def update_user_email(user, token) do
    context = "change:#{user.email}"

    Repo.transact(fn ->
      with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
           %UserToken{sent_to: email} <- Repo.one(query),
           {:ok, user} <- Repo.update(User.email_changeset(user, %{email: email})),
           {_count, _result} <-
             Repo.delete_all(from(UserToken, where: [user_id: ^user.id, context: ^context])) do
        {:ok, user}
      else
        _ -> {:error, :transaction_aborted}
      end
    end)
  end

  def change_user_password(user, attrs \\ %{}, opts \\ []) do
    User.password_changeset(user, attrs, opts)
  end

  def update_user_password(user, attrs) do
    user
    |> User.password_changeset(attrs)
    |> update_user_and_delete_all_tokens()
  end

  ## Session

  def generate_user_session_token(user) do
    {token, user_token} = UserToken.build_session_token(user)
    Repo.insert!(user_token)
    token
  end

  def get_user_by_session_token(token) do
    {:ok, query} = UserToken.verify_session_token_query(token)
    Repo.one(query)
  end

  def get_user_by_magic_link_token(token) do
    with {:ok, query} <- UserToken.verify_magic_link_token_query(token),
         {user, _token} <- Repo.one(query) do
      user
    else
      _ -> nil
    end
  end

  def login_user_by_magic_link(token) do
    {:ok, query} = UserToken.verify_magic_link_token_query(token)

    case Repo.one(query) do
      # Prevent session fixation attacks by disallowing magic links for unconfirmed users with password
      {%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) ->
        raise """
        magic link log in is not allowed for unconfirmed users with a password set!

        This cannot happen with the default implementation, which indicates that you
        might have adapted the code to a different use case. Please make sure to read the
        "Mixing magic link and password registration" section of `mix help phx.gen.auth`.
        """

      {%User{confirmed_at: nil} = user, _token} ->
        user
        |> User.confirm_changeset()
        |> update_user_and_delete_all_tokens()

      {user, token} ->
        Repo.delete!(token)
        {:ok, {user, []}}

      nil ->
        {:error, :not_found}
    end
  end

  @doc ~S"""
  Delivers the update email instructions to the given user.

  ## Examples

      iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm-email/#{&1}"))
      {:ok, %{to: ..., body: ...}}

  """
  def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
      when is_function(update_email_url_fun, 1) do
    {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")

    Repo.insert!(user_token)
    UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
  end

  def deliver_login_instructions(%User{} = user, magic_link_url_fun)
      when is_function(magic_link_url_fun, 1) do
    {encoded_token, user_token} = UserToken.build_email_token(user, "login")
    Repo.insert!(user_token)
    UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token))
  end

  def delete_user_session_token(token) do
    Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"]))
    :ok
  end

  ## Token helper

  defp update_user_and_delete_all_tokens(changeset) do
    Repo.transact(fn ->
      with {:ok, user} <- Repo.update(changeset) do
        tokens_to_expire = Repo.all_by(UserToken, user_id: user.id)

        Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id)))

        {:ok, {user, tokens_to_expire}}
      end
    end)
  end

  ## Organizations

  alias Hello.Accounts.{Organization, Scope}

  def create_organization(%Scope{} = scope, attrs) do
    %Organization{}
    |> Organization.changeset(attrs)
    |> Ecto.Changeset.put_assoc(:users, [scope.user])
    |> Repo.insert()
  end

  # Sua vez: só para membros. Esta versão devolve qualquer organização pelo slug.
  def get_organization_by_slug!(%Scope{} = _scope, slug) do
    Repo.get_by!(Organization, slug: slug)
  end

  def get_organization!(id), do: Repo.get!(Organization, id)

  def get_organization_by_slug!(slug) when is_binary(slug) do
    Repo.get_by!(Organization, slug: slug)
  end
end

A organização atual faz parte do caminho, como em http://localhost:4000/organizations/acme/posts. O guia acrescenta ao user_auth.ex um plug que lê o slug de conn.params["org"], busca a organização com o escopo atual e guarda o escopo ampliado:

# user_auth.ex, como o guia mostra
def assign_org_to_scope(conn, _opts) do
  current_scope = conn.assigns.current_scope
  if slug = conn.params["org"] do
    org = MyApp.Accounts.get_organization_by_slug!(current_scope, slug)
    assign(conn, :current_scope, MyApp.Accounts.Scope.put_organization(current_scope, org))
  else
    conn
  end
end

Um plug de pipeline enxerga conn.params["org"]? Enxerga: o router junta os parâmetros do caminho aos parâmetros do conn antes de rodar os pipelines. O guia põe o plug no pipeline :browser, logo depois de fetch_current_scope_for_user.

Trilha.pergunta(:org_no_browser)

Foi isso que aconteceu ao testar a versão do guia: sem login, GET /organizations/acme/articles levanta FunctionClauseError em get_organization_by_slug!/2, um erro 500. Logado, tudo funciona. Por isso o router deste notebook põe o plug no pipe_through do bloco das organizações, depois de :require_authenticated_user. A função fica no próprio router, para não repetir as quase 200 linhas do UserAuth; para o Phoenix, é o mesmo plug de função.

O escopo da organização entra na configuração com dois campos novos, route_prefix e route_access_path:

config :hello, :scopes,
  user: [
    # ...
  ],
  organization: [
    module: Hello.Accounts.Scope,
    assign_key: :current_scope,
    access_path: [:organization, :id],
    route_prefix: "/organizations/:org",
    route_access_path: [:organization, :slug],
    schema_key: :org_id,
    schema_type: :id,
    schema_table: :organizations,
    test_data_fixture: Hello.AccountsFixtures,
    test_setup_helper: :register_and_log_in_user_with_org
  ]

O guia gera de novo Blog Post, com --scope organization. Aqui o Blog já tem Post, com o escopo do usuário, então o recurso da organização se chama Article, no mesmo contexto. O gerador avisa que vai acrescentar a um contexto existente:

$ mix phx.gen.html Blog Article articles title:string body:text --scope organization
You are generating into an existing context.

The Hello.Blog context currently has 7 functions and 1 file in its directory.

  * It's OK to have multiple resources in the same context as long as they are closely related. But if a context grows too large, consider breaking it apart

  * If they are not closely related, another context probably works better

The fact that two entities are related in the database does not mean they belong to the same context.

If you are not sure, prefer creating a new context over adding to the existing one.

Would you like to proceed? [Yn] y
* creating lib/hello_web/controllers/article_controller.ex
* creating lib/hello_web/controllers/article_html/edit.html.heex
* creating lib/hello_web/controllers/article_html/index.html.heex
* creating lib/hello_web/controllers/article_html/new.html.heex
* creating lib/hello_web/controllers/article_html/show.html.heex
* creating lib/hello_web/controllers/article_html/article_form.html.heex
* creating lib/hello_web/controllers/article_html.ex
* creating test/hello_web/controllers/article_controller_test.exs
* creating lib/hello/blog/article.ex
* creating priv/repo/migrations/20250612100000_create_articles.exs
* injecting lib/hello/blog.ex
* injecting test/hello/blog_test.exs
* injecting test/support/fixtures/blog_fixtures.ex

Add the resource to your browser scope in lib/hello_web/router.ex:

    resources "/organizations/:org/articles", ArticleController

Ensure the routes are defined in a block that sets the `:current_scope` assign.

Remember to update your repository by running migrations:

    $ mix ecto.migrate

A rota sugerida já tem o prefixo /organizations/:org. O mesmo contexto passa a ter funções com dois escopos diferentes, e isso é permitido: o Post é do usuário, o Article é da organização.

priv/repo/migrations/20250612100000_create_articles.exs: a coluna é org_id, com chave estrangeira para organizations (schema_key e schema_table do escopo).

defmodule Hello.Repo.Migrations.CreateArticles do
  use Ecto.Migration

  def change do
    create table(:articles) do
      add :title, :string
      add :body, :text
      add :org_id, references(:organizations, on_delete: :delete_all)

      timestamps(type: :utc_datetime)
    end

    create index(:articles, [:org_id])
  end
end

Trilha.migracao(20250612100000, Hello.Repo.Migrations.CreateArticles)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)

lib/hello/blog/article.ex e lib/hello/blog.ex: as funções novas usam scope.organization.id (o access_path) e tópicos "organization:#{key}:articles".

defmodule Hello.Blog.Article do
  use Ecto.Schema
  import Ecto.Changeset

  schema "articles" do
    field :title, :string
    field :body, :string
    field :org_id, :id

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(article, attrs, organization_scope) do
    article
    |> cast(attrs, [:title, :body])
    |> validate_required([:title, :body])
    |> put_change(:org_id, organization_scope.organization.id)
  end
end
defmodule Hello.Blog do
  @moduledoc """
  The Blog context.
  """

  import Ecto.Query, warn: false
  alias Hello.Repo

  alias Hello.Blog.Post
  alias Hello.Accounts.Scope

  def subscribe_posts(%Scope{} = scope) do
    key = scope.user.id

    Phoenix.PubSub.subscribe(Hello.PubSub, "user:#{key}:posts")
  end

  defp broadcast_post(%Scope{} = scope, message) do
    key = scope.user.id

    Phoenix.PubSub.broadcast(Hello.PubSub, "user:#{key}:posts", message)
  end

  def list_posts(%Scope{} = scope) do
    Repo.all_by(Post, user_id: scope.user.id)
  end

  def get_post!(%Scope{} = scope, id) do
    Repo.get_by!(Post, id: id, user_id: scope.user.id)
  end

  def create_post(%Scope{} = scope, attrs) do
    with {:ok, post = %Post{}} <-
           %Post{}
           |> Post.changeset(attrs, scope)
           |> Repo.insert() do
      broadcast_post(scope, {:created, post})
      {:ok, post}
    end
  end

  def update_post(%Scope{} = scope, %Post{} = post, attrs) do
    true = post.user_id == scope.user.id

    with {:ok, post = %Post{}} <-
           post
           |> Post.changeset(attrs, scope)
           |> Repo.update() do
      broadcast_post(scope, {:updated, post})
      {:ok, post}
    end
  end

  def delete_post(%Scope{} = scope, %Post{} = post) do
    true = post.user_id == scope.user.id

    with {:ok, post = %Post{}} <-
           Repo.delete(post) do
      broadcast_post(scope, {:deleted, post})
      {:ok, post}
    end
  end

  def change_post(%Scope{} = scope, %Post{} = post, attrs \\ %{}) do
    true = post.user_id == scope.user.id

    Post.changeset(post, attrs, scope)
  end

  alias Hello.Blog.Article
  alias Hello.Accounts.Scope

  def subscribe_articles(%Scope{} = scope) do
    key = scope.organization.id

    Phoenix.PubSub.subscribe(Hello.PubSub, "organization:#{key}:articles")
  end

  defp broadcast_article(%Scope{} = scope, message) do
    key = scope.organization.id

    Phoenix.PubSub.broadcast(Hello.PubSub, "organization:#{key}:articles", message)
  end

  def list_articles(%Scope{} = scope) do
    Repo.all_by(Article, org_id: scope.organization.id)
  end

  def get_article!(%Scope{} = scope, id) do
    Repo.get_by!(Article, id: id, org_id: scope.organization.id)
  end

  def create_article(%Scope{} = scope, attrs) do
    with {:ok, article = %Article{}} <-
           %Article{}
           |> Article.changeset(attrs, scope)
           |> Repo.insert() do
      broadcast_article(scope, {:created, article})
      {:ok, article}
    end
  end

  def update_article(%Scope{} = scope, %Article{} = article, attrs) do
    true = article.org_id == scope.organization.id

    with {:ok, article = %Article{}} <-
           article
           |> Article.changeset(attrs, scope)
           |> Repo.update() do
      broadcast_article(scope, {:updated, article})
      {:ok, article}
    end
  end

  def delete_article(%Scope{} = scope, %Article{} = article) do
    true = article.org_id == scope.organization.id

    with {:ok, article = %Article{}} <-
           Repo.delete(article) do
      broadcast_article(scope, {:deleted, article})
      {:ok, article}
    end
  end

  def change_article(%Scope{} = scope, %Article{} = article, attrs \\ %{}) do
    true = article.org_id == scope.organization.id

    Article.changeset(article, attrs, scope)
  end
end

lib/hello_web/router.ex: o plug e o bloco das organizações.

defmodule HelloWeb.Router do
  use HelloWeb, :router

  import HelloWeb.UserAuth

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {HelloWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug :fetch_current_scope_for_user
    plug :assign_scope
  end

  defp assign_scope(conn, _opts) do
    if id = get_session(conn, :scope_id) do
      assign(conn, :session_scope, Hello.Scope.for_id(id))
    else
      id = System.unique_integer()

      conn
      |> put_session(:scope_id, id)
      |> assign(:session_scope, Hello.Scope.for_id(id))
    end
  end

  # O guia põe esta função em user_auth.ex, junto dos outros plugs de
  # autenticação. Aqui ela fica no router, para não repetir o UserAuth inteiro.
  defp assign_org_to_scope(conn, _opts) do
    current_scope = conn.assigns.current_scope

    if slug = conn.params["org"] do
      org = Hello.Accounts.get_organization_by_slug!(current_scope, slug)
      assign(conn, :current_scope, Hello.Accounts.Scope.put_organization(current_scope, org))
    else
      conn
    end
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :home

    resources "/notes", NoteController
  end

  # Em desenvolvimento: a caixa de entrada do Swoosh.
  scope "/dev" do
    pipe_through :browser

    forward "/mailbox", Plug.Swoosh.MailboxPreview
  end

  ## Authentication routes

  scope "/", HelloWeb do
    pipe_through [:browser, :redirect_if_user_is_authenticated]

    get "/users/register", UserRegistrationController, :new
    post "/users/register", UserRegistrationController, :create
  end

  scope "/", HelloWeb do
    pipe_through [:browser, :require_authenticated_user]

    get "/users/settings", UserSettingsController, :edit
    put "/users/settings", UserSettingsController, :update
    get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email

    resources "/posts", PostController
  end

  scope "/", HelloWeb do
    pipe_through [:browser, :require_authenticated_user, :assign_org_to_scope]

    resources "/organizations/:org/articles", ArticleController
  end

  scope "/", HelloWeb do
    pipe_through [:browser]

    get "/users/log-in", UserSessionController, :new
    get "/users/log-in/:token", UserSessionController, :confirm
    post "/users/log-in", UserSessionController, :create
    delete "/users/log-out", UserSessionController, :delete
  end
end

lib/hello_web/controllers/article_controller.ex e article_html.ex: todos os caminhos usam @current_scope.organization.slug, o route_access_path. Nenhuma função do controller recebe org dos parâmetros: o plug já pôs a organização no escopo.

defmodule HelloWeb.ArticleController do
  use HelloWeb, :controller

  alias Hello.Blog
  alias Hello.Blog.Article

  def index(conn, _params) do
    articles = Blog.list_articles(conn.assigns.current_scope)
    render(conn, :index, articles: articles)
  end

  def new(conn, _params) do
    changeset =
      Blog.change_article(conn.assigns.current_scope, %Article{
        org_id: conn.assigns.current_scope.organization.id
      })

    render(conn, :new, changeset: changeset)
  end

  def create(conn, %{"article" => article_params}) do
    case Blog.create_article(conn.assigns.current_scope, article_params) do
      {:ok, article} ->
        conn
        |> put_flash(:info, "Article created successfully.")
        |> redirect(to: ~p"/organizations/#{conn.assigns.current_scope.organization.slug}/articles/#{article}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :new, changeset: changeset)
    end
  end

  def show(conn, %{"id" => id}) do
    article = Blog.get_article!(conn.assigns.current_scope, id)
    render(conn, :show, article: article)
  end

  def edit(conn, %{"id" => id}) do
    article = Blog.get_article!(conn.assigns.current_scope, id)
    changeset = Blog.change_article(conn.assigns.current_scope, article)
    render(conn, :edit, article: article, changeset: changeset)
  end

  def update(conn, %{"id" => id, "article" => article_params}) do
    article = Blog.get_article!(conn.assigns.current_scope, id)

    case Blog.update_article(conn.assigns.current_scope, article, article_params) do
      {:ok, article} ->
        conn
        |> put_flash(:info, "Article updated successfully.")
        |> redirect(to: ~p"/organizations/#{conn.assigns.current_scope.organization.slug}/articles/#{article}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :edit, article: article, changeset: changeset)
    end
  end

  def delete(conn, %{"id" => id}) do
    article = Blog.get_article!(conn.assigns.current_scope, id)
    {:ok, _article} = Blog.delete_article(conn.assigns.current_scope, article)

    conn
    |> put_flash(:info, "Article deleted successfully.")
    |> redirect(to: ~p"/organizations/#{conn.assigns.current_scope.organization.slug}/articles")
  end
end
defmodule HelloWeb.ArticleHTML do
  use HelloWeb, :html

  # article_html/edit.html.heex
  def edit(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope}>
      <.header>
        Edit Article {@article.id}
        <:subtitle>Use this form to manage article records in your database.</:subtitle>
      </.header>

      <.article_form changeset={@changeset} action={~p"/organizations/#{@current_scope.organization.slug}/articles/#{@article}"} return_to={~p"/organizations/#{@current_scope.organization.slug}/articles"} />
    </Layouts.app>
    """
  end

  # article_html/index.html.heex
  def index(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope}>
      <.header>
        Listing Articles
        <:actions>
          <.button href={~p"/organizations/#{@current_scope.organization.slug}/articles/new"}>
            <.icon name="hero-plus" /> New Article
          </.button>
        </:actions>
      </.header>

      <.table id="articles" rows={@articles} row_click={&JS.navigate(~p"/organizations/#{@current_scope.organization.slug}/articles/#{&1}")}>
        <:col :let={article} label="Title">{article.title}</:col>
        <:col :let={article} label="Body">{article.body}</:col>
        <:action :let={article}>
          <div class="sr-only">
            <.link navigate={~p"/organizations/#{@current_scope.organization.slug}/articles/#{article}"}>Show</.link>
          </div>
          <.link navigate={~p"/organizations/#{@current_scope.organization.slug}/articles/#{article}/edit"}>Edit</.link>
        </:action>
        <:action :let={article}>
          <.link href={~p"/organizations/#{@current_scope.organization.slug}/articles/#{article}"} method="delete" data-confirm="Are you sure?">
            Delete
          </.link>
        </:action>
      </.table>
    </Layouts.app>
    """
  end

  # article_html/new.html.heex
  def new(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope}>
      <.header>
        New Article
        <:subtitle>Use this form to manage article records in your database.</:subtitle>
      </.header>

      <.article_form changeset={@changeset} action={~p"/organizations/#{@current_scope.organization.slug}/articles"} return_to={~p"/organizations/#{@current_scope.organization.slug}/articles"} />
    </Layouts.app>
    """
  end

  # article_html/show.html.heex
  def show(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope}>
      <.header>
        Article {@article.id}
        <:subtitle>This is a article record from your database.</:subtitle>
        <:actions>
          <.button navigate={~p"/organizations/#{@current_scope.organization.slug}/articles"}>
            <.icon name="hero-arrow-left" />
          </.button>
          <.button variant="primary" navigate={~p"/organizations/#{@current_scope.organization.slug}/articles/#{@article}/edit?return_to=show"}>
            <.icon name="hero-pencil-square" /> Edit article
          </.button>
        </:actions>
      </.header>

      <.list>
        <:item title="Title">{@article.title}</:item>
        <:item title="Body">{@article.body}</:item>
      </.list>
    </Layouts.app>
    """
  end

  @doc """
  Renders a article form.

  The form is defined in the template at
  article_html/article_form.html.heex
  """
  attr :changeset, Ecto.Changeset, required: true
  attr :action, :string, required: true
  attr :return_to, :string, default: nil

  # article_html/article_form.html.heex
  def article_form(assigns) do
    ~H"""
    <.form :let={f} for={@changeset} action={@action}>
      <.input field={f[:title]} type="text" label="Title" />
      <.input field={f[:body]} type="textarea" label="Body" />
      <footer>
        <.button variant="primary">Save Article</.button>
        <.button :if={@return_to} href={@return_to}>Cancel</.button>
      </footer>
    </.form>
    """
  end
end

Duas organizações para testar: a Acme, com a Ana, e a Globex, com a Bia. create_organization/2 põe o usuário do escopo como membro.

alias Hello.Accounts
alias Hello.Accounts.Scope

ana = Scope.for_user(Accounts.get_user_by_email("ana@example.com"))
bia = Scope.for_user(Accounts.get_user_by_email("bia@example.com"))

{:ok, acme} = Accounts.create_organization(ana, %{name: "Acme", slug: "acme"})
{:ok, globex} = Accounts.create_organization(bia, %{name: "Globex", slug: "globex"})

{acme.slug, globex.slug, Phoenix.Param.to_param(acme)}

A Ana escreve um artigo na Acme:

Trilha.navegar("ana", :post, "/organizations/acme/articles", %{"article" => %{"title" => "Artigo da Acme", "body" => "Para os membros da Acme."}})

E agora o defeito. A Ana não é membro da Globex. O que ela vê em /organizations/globex/articles?

Trilha.navegar("ana", :get, "/organizations/globex/articles")

Com a versão de get_organization_by_slug!/2 que está na célula do Hello.Accounts, a resposta é 200 e a página "Listing Articles" da Globex: a Ana entra numa organização que não é dela só trocando o slug na URL. É o "broken access control" do começo do notebook, agora na camada que monta o escopo.

Sua vez: só para membros

Corrija get_organization_by_slug!/2 na célula do Hello.Accounts, lá em cima: a função deve devolver a organização só se o usuário do escopo for membro dela, e levantar Ecto.NoResultsError nos outros casos. A associação :users do schema e um join resolvem. Avalie a célula de novo e confira.

Trilha.conferir(:org_por_slug)
Trilha.solucao(:org_por_slug)

Avalie de novo a célula da Ana na Globex: depois da correção, ela recebe Ecto.NoResultsError, com a consulta filtrando por u1.id == ^1. A Bia continua abrindo a Globex normalmente:

Trilha.navegar("bia", :get, "/organizations/globex/articles")

Para LiveViews, o guia mostra um hook on_mount com a mesma lógica, :assign_org_to_scope, acrescentado à live_session; e, para os testes, uma fixture organization_scope_fixture/1 e um setup register_and_log_in_user_with_org/1. Eles seguem o mesmo desenho do plug e das fixtures da seção anterior.

Funções auxiliares no escopo

Na documentação: Scope helpers

Com escopos mais complexos, montar um à mão no console fica trabalhoso: buscar o usuário, buscar a organização, chamar for_user/1 e put_organization/2. O guia sugere uma função auxiliar no próprio módulo do escopo, for/1, que aceita o usuário (pelo id ou pelo e-mail) e a organização (pelo id ou pelo slug):

Hello.Blog.list_articles(Scope.for(user: 1, org: "acme"))

Sua vez: Scope.for/1

Escreva for/1 na célula abaixo, como no guia:

  • for(user: u, org: o): o escopo do usuário com a organização;
  • for(user: u): só o usuário;
  • for(org: o): um escopo só com a organização, sem usuário.

Um id (inteiro) busca com Accounts.get_user!/1 ou Accounts.get_organization!/1; um texto busca com Accounts.get_user_by_email/1 ou Accounts.get_organization_by_slug!/1. Funções defp com guardas when is_integer/1 e when is_binary/1 deixam o código curto.

defmodule Hello.Accounts.Scope do
  @moduledoc """
  Defines the scope of the caller to be used throughout the app.
  """

  alias Hello.Accounts.User
  alias Hello.Accounts.Organization

  defstruct user: nil, organization: nil

  @doc """
  Creates a scope for the given user.

  Returns nil if no user is given.
  """
  def for_user(%User{} = user) do
    %__MODULE__{user: user}
  end

  def for_user(nil), do: nil

  def put_organization(%__MODULE__{} = scope, %Organization{} = organization) do
    %{scope | organization: organization}
  end

  # Sua vez: Scope.for/1.
end

Até for/1 existir, a célula de uso, mais abaixo, levanta UndefinedFunctionError.

Trilha.conferir(:scope_for)
Trilha.solucao(:scope_for)

Com for/1, o console vira um lugar confortável para explorar os dados de qualquer usuário:

alias Hello.Accounts.Scope

%{
  posts_da_ana: Hello.Blog.list_posts(Scope.for(user: "ana@example.com")) |> Enum.map(& &1.title),
  artigos_da_acme: Hello.Blog.list_articles(Scope.for(user: 1, org: "acme")) |> Enum.map(& &1.title),
  so_a_organizacao: Scope.for(org: "globex") |> Map.take([:user, :organization]) |> Map.update!(:organization, & &1.slug)
}

Num projeto, ponha alias Hello.Accounts.Scope no arquivo .iex.exs, na raiz, e o iex -S mix já começa com o alias. Note que for(org: ...) e as funções get_organization!/1 e get_organization_by_slug!/1 ignoram as permissões de propósito: são ferramentas de console, não algo para chamar a partir de um controller.

Sem roteiro: as lojas do Brechó

O Brechó vai virar uma plataforma de várias lojas. Cada loja tem os seus membros (usuários) e os seus produtos, e cada membro cuida só dos produtos das lojas de que faz parte. É o desenho das organizações, com outros nomes, e desta vez sem roteiro.

O que a aplicação precisa ter:

  1. Migration 20250613090000: a tabela lojas (nome, slug único, timestamps), a tabela de junção lojas_usuarios (loja_id, user_id, sem chave primária, um par único) e a tabela produtos (nome, preco_centavos, loja_id com chave estrangeira, timestamps). Apagar uma loja apaga os produtos e os vínculos.
  2. Schemas Hello.Lojas.Loja (com o slug nas rotas e many_to_many :usuarios) e Hello.Lojas.Produto (com changeset/3, que recebe o escopo e põe o loja_id, como os gerados).
  3. Escopo: Hello.Accounts.Scope ganha o campo loja e put_loja/2, sem perder organization nem for/1.
  4. Contexto Hello.Lojas: buscar_loja!/2 (só para membros), listar_produtos/1 e cadastrar_produto/2, que usam a loja do escopo, e, se quiser, criar_loja/2.
  5. Router: um plug assign_loja_to_scope e as rotas GET /lojas/:loja/produtos e POST /lojas/:loja/produtos, atrás do login.
  6. Web: um ProdutoController com index e create (que volta para a lista com uma flash) e um ProdutoHTML com a lista da loja.

A conferência cria as próprias lojas e produtos, direto no banco, e os apaga no fim. Ela confere que um membro vê só os produtos da loja dele, que quem não é membro recebe Ecto.NoResultsError (404 em produção), que um visitante sem login vai para /users/log-in e que o cadastro grava o produto na loja certa.

Se quiser comparar com um projeto de verdade: com um escopo loja na configuração (copie o de organization, trocando os nomes), mix phx.gen.html Lojas Produto produtos nome:string preco_centavos:integer --scope loja gera a maior parte das peças 2, 4 e 6.

# Brechó: a migration das lojas (priv/repo/migrations/20250613090000_create_lojas.exs)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
# Brechó: os schemas Hello.Lojas.Loja e Hello.Lojas.Produto
# Brechó: o Hello.Accounts.Scope com a loja
# Brechó: o contexto Hello.Lojas
# Brechó: o router com o plug e as rotas das lojas
# Brechó: o ProdutoController e o ProdutoHTML
Trilha.conferir(:brecho)
Trilha.solucao(:brecho)

No seu projeto

Para repetir este notebook num projeto gerado por mix phx.new com mix phx.gen.auth (o do notebook 13):

  1. Abra config/config.exs e leia o bloco config :hello, :scopes. É ele que faz os geradores escreverem código com escopo.
  2. Gere o blog: mix phx.gen.html Blog Post posts title:string body:text, ponha resources "/posts", PostController no bloco com :require_authenticated_user e rode mix ecto.migrate. Abra lib/hello/blog.ex e procure scope.user.id.
  3. Rode mix test test/hello/blog_test.exs. Os testes gerados usam user_scope_fixture/0 (a opção test_data_fixture) e conferem que outro escopo não enxerga os posts.
  4. Para o escopo de sessão: crie lib/hello/scope.ex, o plug assign_scope, o bloco session: na configuração (com assign_key: :session_scope, se o projeto já tem o phx.gen.auth) e rode mix phx.gen.html Notes Note notes title:string. Os geradores leem a configuração na hora; não é preciso recompilar nada antes.
  5. Para as organizações: escreva a migration, o schema Organization, os campos novos do Scope, as funções do contexto e o plug; acrescente o escopo organization: e rode mix phx.gen.html Blog Article articles title:string body:text --scope organization.
  6. Crie .iex.exs com alias Hello.Accounts.Scope e experimente Scope.for/1 no iex -S mix.

Duas lembranças do caminho:

  • com --scope e uma assign_key diferente de :current_scope, os templates gerados passam esse atributo ao Layouts.app; declare-o no layout;
  • o plug que amplia o escopo precisa rodar depois de :require_authenticated_user, ou quem não fez login recebe um erro 500.

Revisão

  • Um escopo diz quem faz a requisição (o usuário, a sessão, a organização) e viaja nos assigns, em :current_scope por padrão. Esquecer de filtrar os dados por ele é o "broken access control", o primeiro risco da lista da OWASP.
  • O phx.gen.auth gera Hello.Accounts.Scope, preenchido a cada requisição por fetch_current_scope_for_user. Sem login, o escopo é nil.
  • Com um escopo padrão na configuração, os geradores escrevem contextos em que cada função recebe o escopo primeiro, filtra as consultas por ele, confere o dono com true = ... e publica no PubSub em tópicos do escopo. O schema ganha a coluna do escopo, posta pelo changeset/3, nunca pelos parâmetros.
  • As opções da configuração (module, assign_key, access_path, schema_key, schema_table, route_prefix, route_access_path...) descrevem o escopo para os geradores. Um escopo pode não ter tabela (schema_table: nil) e pode morar em outra chave dos assigns.
  • Ampliar um escopo é acrescentar campos à struct e um plug que os preenche a partir da requisição, sempre consultando com o escopo que já existe, para que ninguém entre onde não é membro.
  • O scope do router e o current_scope são coisas diferentes: um agrupa rotas e plugs, o outro filtra dados.

O próximo notebook da trilha segue o guia API Authentication: tokens de API em cima do mesmo phx.gen.auth.