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(["&", "<", ">", """, "'", " "], fn
"&" -> "&"
"<" -> "<"
">" -> ">"
""" -> "\""
"'" -> "'"
" " -> " "
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.authgera; - 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/1eget_post!/2filtram poruser_id: scope.user.id;update_post/3,delete_post/2echange_post/3começam comtrue = post.user_id == scope.user.id;subscribe_posts/1ebroadcast_post/2usam 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:
- Migration
20250613090000: a tabelalojas(nome,slugúnico, timestamps), a tabela de junçãolojas_usuarios(loja_id,user_id, sem chave primária, um par único) e a tabelaprodutos(nome,preco_centavos,loja_idcom chave estrangeira, timestamps). Apagar uma loja apaga os produtos e os vínculos. - Schemas
Hello.Lojas.Loja(com o slug nas rotas emany_to_many :usuarios) eHello.Lojas.Produto(comchangeset/3, que recebe o escopo e põe oloja_id, como os gerados). - Escopo:
Hello.Accounts.Scopeganha o campolojaeput_loja/2, sem perderorganizationnemfor/1. - Contexto
Hello.Lojas:buscar_loja!/2(só para membros),listar_produtos/1ecadastrar_produto/2, que usam a loja do escopo, e, se quiser,criar_loja/2. - Router: um plug
assign_loja_to_scopee as rotasGET /lojas/:loja/produtosePOST /lojas/:loja/produtos, atrás do login. - Web: um
ProdutoControllercomindexecreate(que volta para a lista com uma flash) e umProdutoHTMLcom 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):
- Abra
config/config.exse leia o blococonfig :hello, :scopes. É ele que faz os geradores escreverem código com escopo. - Gere o blog:
mix phx.gen.html Blog Post posts title:string body:text, ponharesources "/posts", PostControllerno bloco com:require_authenticated_usere rodemix ecto.migrate. Abralib/hello/blog.exe procurescope.user.id. - Rode
mix test test/hello/blog_test.exs. Os testes gerados usamuser_scope_fixture/0(a opçãotest_data_fixture) e conferem que outro escopo não enxerga os posts. - Para o escopo de sessão: crie
lib/hello/scope.ex, o plugassign_scope, o blocosession:na configuração (comassign_key: :session_scope, se o projeto já tem ophx.gen.auth) e rodemix phx.gen.html Notes Note notes title:string. Os geradores leem a configuração na hora; não é preciso recompilar nada antes. - Para as organizações: escreva a migration, o schema
Organization, os campos novos doScope, as funções do contexto e o plug; acrescente o escopoorganization:e rodemix phx.gen.html Blog Article articles title:string body:text --scope organization. - Crie
.iex.exscomalias Hello.Accounts.Scopee experimenteScope.for/1noiex -S mix.
Duas lembranças do caminho:
- com
--scopee umaassign_keydiferente de:current_scope, os templates gerados passam esse atributo aoLayouts.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_scopepor padrão. Esquecer de filtrar os dados por ele é o "broken access control", o primeiro risco da lista da OWASP. - O
phx.gen.authgeraHello.Accounts.Scope, preenchido a cada requisição porfetch_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 pelochangeset/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
scopedo router e ocurrent_scopesã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.