From 0c9372cb2f30c5c8348d2a0710e20677fbe27066 Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Mon, 23 Mar 2026 20:30:17 -0300 Subject: [PATCH 1/5] feat: add devices and device_init_tokens tables and schemas --- pretex/lib/pretex/devices/device.ex | 27 +++++++++++++++ .../lib/pretex/devices/device_init_token.ex | 21 ++++++++++++ .../20260323232908_create_devices.exs | 33 +++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 pretex/lib/pretex/devices/device.ex create mode 100644 pretex/lib/pretex/devices/device_init_token.ex create mode 100644 pretex/priv/repo/migrations/20260323232908_create_devices.exs diff --git a/pretex/lib/pretex/devices/device.ex b/pretex/lib/pretex/devices/device.ex new file mode 100644 index 0000000..e1151c6 --- /dev/null +++ b/pretex/lib/pretex/devices/device.ex @@ -0,0 +1,27 @@ +defmodule Pretex.Devices.Device do + use Ecto.Schema + import Ecto.Changeset + + @statuses ~w(active revoked) + + schema "devices" do + field(:name, :string) + field(:api_token_hash, :string) + field(:status, :string, default: "active") + field(:last_seen_at, :utc_datetime) + field(:provisioned_at, :utc_datetime) + + belongs_to(:organization, Pretex.Organizations.Organization) + belongs_to(:provisioned_by, Pretex.Accounts.User, foreign_key: :provisioned_by_id) + + timestamps(type: :utc_datetime) + end + + def changeset(device, attrs) do + device + |> cast(attrs, [:name, :status, :last_seen_at]) + |> validate_required([:name]) + |> validate_length(:name, min: 1, max: 255) + |> validate_inclusion(:status, @statuses) + end +end diff --git a/pretex/lib/pretex/devices/device_init_token.ex b/pretex/lib/pretex/devices/device_init_token.ex new file mode 100644 index 0000000..3d40804 --- /dev/null +++ b/pretex/lib/pretex/devices/device_init_token.ex @@ -0,0 +1,21 @@ +defmodule Pretex.Devices.DeviceInitToken do + use Ecto.Schema + import Ecto.Changeset + + schema "device_init_tokens" do + field(:token_hash, :string) + field(:expires_at, :utc_datetime) + field(:used_at, :utc_datetime) + + belongs_to(:organization, Pretex.Organizations.Organization) + belongs_to(:created_by, Pretex.Accounts.User, foreign_key: :created_by_id) + + timestamps(type: :utc_datetime) + end + + def changeset(token, attrs) do + token + |> cast(attrs, [:expires_at, :used_at]) + |> validate_required([:expires_at]) + end +end diff --git a/pretex/priv/repo/migrations/20260323232908_create_devices.exs b/pretex/priv/repo/migrations/20260323232908_create_devices.exs new file mode 100644 index 0000000..156295b --- /dev/null +++ b/pretex/priv/repo/migrations/20260323232908_create_devices.exs @@ -0,0 +1,33 @@ +defmodule Pretex.Repo.Migrations.CreateDevices do + use Ecto.Migration + + def change do + create table(:device_init_tokens) do + add :token_hash, :string, null: false + add :organization_id, references(:organizations, on_delete: :delete_all), null: false + add :created_by_id, references(:users, on_delete: :restrict), null: false + add :expires_at, :utc_datetime, null: false + add :used_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index(:device_init_tokens, [:token_hash]) + create index(:device_init_tokens, [:organization_id]) + + create table(:devices) do + add :name, :string, null: false + add :api_token_hash, :string, null: false + add :status, :string, null: false, default: "active" + add :last_seen_at, :utc_datetime + add :provisioned_at, :utc_datetime, null: false + add :organization_id, references(:organizations, on_delete: :delete_all), null: false + add :provisioned_by_id, references(:users, on_delete: :restrict), null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:devices, [:api_token_hash]) + create index(:devices, [:organization_id]) + end +end From edce2cd5249cc0ccef31c82021550945e32e817e Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Mon, 23 Mar 2026 20:32:31 -0300 Subject: [PATCH 2/5] feat: implement Devices context with token generation and provisioning --- pretex/lib/pretex/devices.ex | 121 +++++++++++++++++++++ pretex/test/pretex/devices_test.exs | 156 ++++++++++++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 pretex/lib/pretex/devices.ex create mode 100644 pretex/test/pretex/devices_test.exs diff --git a/pretex/lib/pretex/devices.ex b/pretex/lib/pretex/devices.ex new file mode 100644 index 0000000..d1c5d57 --- /dev/null +++ b/pretex/lib/pretex/devices.ex @@ -0,0 +1,121 @@ +defmodule Pretex.Devices do + @moduledoc "Manages device provisioning and authentication." + + import Ecto.Query + + alias Pretex.Repo + alias Pretex.Devices.{Device, DeviceInitToken} + + @token_expiry_hours 24 + + def hash_token(token) do + :crypto.hash(:sha256, token) |> Base.encode16(case: :lower) + end + + def generate_init_token(organization_id, user_id) do + code = generate_short_code() + hash = hash_token(code) + + expires_at = + DateTime.utc_now() + |> DateTime.add(@token_expiry_hours * 3600, :second) + |> DateTime.truncate(:second) + + %DeviceInitToken{} + |> DeviceInitToken.changeset(%{expires_at: expires_at}) + |> Ecto.Changeset.put_change(:token_hash, hash) + |> Ecto.Changeset.put_change(:organization_id, organization_id) + |> Ecto.Changeset.put_change(:created_by_id, user_id) + |> Repo.insert() + |> case do + {:ok, _} -> {:ok, code} + {:error, cs} -> {:error, cs} + end + end + + def provision_device(token_code, device_name) do + hash = hash_token(token_code) + + case Repo.get_by(DeviceInitToken, token_hash: hash) do + nil -> + {:error, :invalid_token} + + %{used_at: used_at} when not is_nil(used_at) -> + {:error, :token_already_used} + + token -> + if DateTime.compare(token.expires_at, DateTime.utc_now()) == :lt do + {:error, :token_expired} + else + create_device_from_token(token, device_name) + end + end + end + + defp create_device_from_token(token, device_name) do + api_token = :crypto.strong_rand_bytes(32) |> Base.encode16(case: :lower) + api_token_hash = hash_token(api_token) + now = DateTime.utc_now() |> DateTime.truncate(:second) + + Repo.transaction(fn -> + token + |> Ecto.Changeset.change(used_at: now) + |> Repo.update!() + + device = + %Device{} + |> Device.changeset(%{name: device_name}) + |> Ecto.Changeset.put_change(:api_token_hash, api_token_hash) + |> Ecto.Changeset.put_change(:organization_id, token.organization_id) + |> Ecto.Changeset.put_change(:provisioned_by_id, token.created_by_id) + |> Ecto.Changeset.put_change(:provisioned_at, now) + |> Repo.insert!() + + %{device: device, api_token: api_token} + end) + end + + def list_devices(organization_id) do + Device + |> where([d], d.organization_id == ^organization_id) + |> order_by([d], desc: d.provisioned_at) + |> preload(:provisioned_by) + |> Repo.all() + end + + def get_device!(id), do: Repo.get!(Device, id) + + def revoke_device(device_id) do + device = Repo.get!(Device, device_id) + + device + |> Ecto.Changeset.change(status: "revoked") + |> Repo.update() + end + + def authenticate_device(api_token) do + hash = hash_token(api_token) + + case Repo.get_by(Device, api_token_hash: hash) do + nil -> + {:error, :invalid} + + %{status: "revoked"} -> + {:error, :revoked} + + device -> + now = DateTime.utc_now() |> DateTime.truncate(:second) + device |> Ecto.Changeset.change(last_seen_at: now) |> Repo.update() + end + end + + defp generate_short_code do + part = fn -> + :crypto.strong_rand_bytes(2) + |> Base.encode32(case: :upper, padding: false) + |> String.slice(0, 4) + end + + "#{part.()}-#{part.()}" + end +end diff --git a/pretex/test/pretex/devices_test.exs b/pretex/test/pretex/devices_test.exs new file mode 100644 index 0000000..e85148d --- /dev/null +++ b/pretex/test/pretex/devices_test.exs @@ -0,0 +1,156 @@ +defmodule Pretex.DevicesTest do + use Pretex.DataCase, async: true + + import Pretex.OrganizationsFixtures + import Pretex.AccountsFixtures + + alias Pretex.Devices + alias Pretex.Devices.Device + + describe "generate_init_token/2" do + test "generates an 8-char token with dash format" do + org = org_fixture() + user = user_fixture() + + assert {:ok, token_code} = Devices.generate_init_token(org.id, user.id) + assert String.match?(token_code, ~r/^[A-Z0-9]{4}-[A-Z0-9]{4}$/) + end + + test "stores token hashed in the database" do + org = org_fixture() + user = user_fixture() + + {:ok, token_code} = Devices.generate_init_token(org.id, user.id) + hash = Devices.hash_token(token_code) + + assert Pretex.Repo.get_by(Pretex.Devices.DeviceInitToken, token_hash: hash) + end + end + + describe "provision_device/2" do + test "provisions a device with a valid token" do + org = org_fixture() + user = user_fixture() + {:ok, token_code} = Devices.generate_init_token(org.id, user.id) + + assert {:ok, %{device: %Device{} = device, api_token: api_token}} = + Devices.provision_device(token_code, "iPhone de João") + + assert device.name == "iPhone de João" + assert device.organization_id == org.id + assert device.status == "active" + assert device.provisioned_at != nil + assert is_binary(api_token) + end + + test "marks init token as used" do + org = org_fixture() + user = user_fixture() + {:ok, token_code} = Devices.generate_init_token(org.id, user.id) + + {:ok, _} = Devices.provision_device(token_code, "Device") + + hash = Devices.hash_token(token_code) + token = Pretex.Repo.get_by!(Pretex.Devices.DeviceInitToken, token_hash: hash) + assert token.used_at != nil + end + + test "rejects already-used token" do + org = org_fixture() + user = user_fixture() + {:ok, token_code} = Devices.generate_init_token(org.id, user.id) + + {:ok, _} = Devices.provision_device(token_code, "Device 1") + + assert {:error, :token_already_used} = + Devices.provision_device(token_code, "Device 2") + end + + test "rejects expired token" do + org = org_fixture() + user = user_fixture() + {:ok, token_code} = Devices.generate_init_token(org.id, user.id) + + hash = Devices.hash_token(token_code) + token = Pretex.Repo.get_by!(Pretex.Devices.DeviceInitToken, token_hash: hash) + + past = DateTime.utc_now() |> DateTime.add(-3600, :second) |> DateTime.truncate(:second) + token |> Ecto.Changeset.change(expires_at: past) |> Pretex.Repo.update!() + + assert {:error, :token_expired} = + Devices.provision_device(token_code, "Device") + end + + test "rejects invalid token" do + assert {:error, :invalid_token} = + Devices.provision_device("ZZZZ-ZZZZ", "Device") + end + end + + describe "list_devices/1" do + test "returns devices for organization" do + org = org_fixture() + user = user_fixture() + {:ok, token1} = Devices.generate_init_token(org.id, user.id) + {:ok, token2} = Devices.generate_init_token(org.id, user.id) + + {:ok, _} = Devices.provision_device(token1, "Device 1") + {:ok, _} = Devices.provision_device(token2, "Device 2") + + devices = Devices.list_devices(org.id) + assert length(devices) == 2 + end + + test "does not return devices from other organizations" do + org1 = org_fixture() + org2 = org_fixture() + user = user_fixture() + + {:ok, token} = Devices.generate_init_token(org1.id, user.id) + {:ok, _} = Devices.provision_device(token, "Device 1") + + assert Devices.list_devices(org2.id) == [] + end + end + + describe "revoke_device/1" do + test "revokes an active device" do + org = org_fixture() + user = user_fixture() + {:ok, token} = Devices.generate_init_token(org.id, user.id) + {:ok, %{device: device}} = Devices.provision_device(token, "Device") + + assert {:ok, revoked} = Devices.revoke_device(device.id) + assert revoked.status == "revoked" + end + end + + describe "authenticate_device/1" do + test "authenticates with valid API token" do + org = org_fixture() + user = user_fixture() + {:ok, token_code} = Devices.generate_init_token(org.id, user.id) + {:ok, %{api_token: api_token}} = Devices.provision_device(token_code, "Device") + + assert {:ok, device} = Devices.authenticate_device(api_token) + assert device.status == "active" + end + + test "rejects revoked device" do + org = org_fixture() + user = user_fixture() + {:ok, token_code} = Devices.generate_init_token(org.id, user.id) + + {:ok, %{device: device, api_token: api_token}} = + Devices.provision_device(token_code, "Device") + + {:ok, _} = Devices.revoke_device(device.id) + + assert {:error, :revoked} = Devices.authenticate_device(api_token) + end + + test "rejects invalid API token" do + assert {:error, :invalid} = Devices.authenticate_device("bogus-token") + end + end +end From 338cee137a67a168e3887cfc5a95d786048e0003 Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Mon, 23 Mar 2026 20:33:25 -0300 Subject: [PATCH 3/5] feat: add device provisioning API endpoint --- .../controllers/device_controller.ex | 35 +++++++++++++++++++ pretex/lib/pretex_web/router.ex | 8 +++++ 2 files changed, 43 insertions(+) create mode 100644 pretex/lib/pretex_web/controllers/device_controller.ex diff --git a/pretex/lib/pretex_web/controllers/device_controller.ex b/pretex/lib/pretex_web/controllers/device_controller.ex new file mode 100644 index 0000000..d4835e3 --- /dev/null +++ b/pretex/lib/pretex_web/controllers/device_controller.ex @@ -0,0 +1,35 @@ +defmodule PretexWeb.DeviceController do + use PretexWeb, :controller + + alias Pretex.Devices + + def provision(conn, %{"token" => token, "device_name" => device_name}) do + case Devices.provision_device(token, device_name) do + {:ok, %{device: device, api_token: api_token}} -> + device = Pretex.Repo.preload(device, :organization) + + conn + |> put_status(:created) + |> json(%{ + device_id: device.id, + api_token: api_token, + organization_name: device.organization.name + }) + + {:error, :invalid_token} -> + conn |> put_status(:not_found) |> json(%{error: "Token inválido"}) + + {:error, :token_expired} -> + conn |> put_status(:gone) |> json(%{error: "Token expirado"}) + + {:error, :token_already_used} -> + conn |> put_status(:conflict) |> json(%{error: "Token já utilizado"}) + end + end + + def provision(conn, _params) do + conn + |> put_status(:bad_request) + |> json(%{error: "Parâmetros token e device_name são obrigatórios"}) + end +end diff --git a/pretex/lib/pretex_web/router.ex b/pretex/lib/pretex_web/router.ex index 4b23a43..f3f04d1 100644 --- a/pretex/lib/pretex_web/router.ex +++ b/pretex/lib/pretex_web/router.ex @@ -41,6 +41,14 @@ defmodule PretexWeb.Router do post("/payments/:token", PaymentWebhookController, :receive) end + # -- Device provisioning API (no auth — init token is the auth) --------------- + + scope "/api", PretexWeb do + pipe_through(:api) + + post("/devices/provision", DeviceController, :provision) + end + # -- Staff auth (magic link) ----------------------------------------------- scope "/staff", PretexWeb do From b787c9735496a81a841351da30fa4288d92278a4 Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Mon, 23 Mar 2026 20:34:12 -0300 Subject: [PATCH 4/5] feat: add device management LiveView --- .../live/admin/device_live/index.ex | 69 ++++++++++++++ .../live/admin/device_live/index.html.heex | 91 +++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 pretex/lib/pretex_web/live/admin/device_live/index.ex create mode 100644 pretex/lib/pretex_web/live/admin/device_live/index.html.heex diff --git a/pretex/lib/pretex_web/live/admin/device_live/index.ex b/pretex/lib/pretex_web/live/admin/device_live/index.ex new file mode 100644 index 0000000..de11b48 --- /dev/null +++ b/pretex/lib/pretex_web/live/admin/device_live/index.ex @@ -0,0 +1,69 @@ +defmodule PretexWeb.Admin.DeviceLive.Index do + use PretexWeb, :live_view + + alias Pretex.Devices + alias Pretex.Organizations + + @impl true + def mount(%{"org_id" => org_id}, _session, socket) do + org = Organizations.get_organization!(org_id) + devices = Devices.list_devices(org.id) + + socket = + socket + |> assign(:org, org) + |> assign(:devices, devices) + |> assign(:generated_token, nil) + |> assign(:page_title, "Dispositivos — #{org.name}") + + {:ok, socket} + end + + @impl true + def handle_event("generate_token", _, socket) do + org = socket.assigns.org + user_id = socket.assigns.current_user.id + + case Devices.generate_init_token(org.id, user_id) do + {:ok, token_code} -> + {:noreply, assign(socket, :generated_token, token_code)} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Erro ao gerar token.")} + end + end + + @impl true + def handle_event("dismiss_token", _, socket) do + {:noreply, assign(socket, :generated_token, nil)} + end + + @impl true + def handle_event("revoke_device", %{"id" => id_str}, socket) do + id = String.to_integer(id_str) + + case Devices.revoke_device(id) do + {:ok, _} -> + {:noreply, + socket + |> assign(:devices, Devices.list_devices(socket.assigns.org.id)) + |> put_flash(:info, "Acesso do dispositivo revogado.")} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Erro ao revogar dispositivo.")} + end + end + + defp time_ago(nil), do: "Nunca" + + defp time_ago(datetime) do + diff = DateTime.diff(DateTime.utc_now(), datetime, :second) + + cond do + diff < 60 -> "há #{diff}s" + diff < 3600 -> "há #{div(diff, 60)}min" + diff < 86400 -> "há #{div(diff, 3600)}h" + true -> "há #{div(diff, 86400)}d" + end + end +end diff --git a/pretex/lib/pretex_web/live/admin/device_live/index.html.heex b/pretex/lib/pretex_web/live/admin/device_live/index.html.heex new file mode 100644 index 0000000..d801788 --- /dev/null +++ b/pretex/lib/pretex_web/live/admin/device_live/index.html.heex @@ -0,0 +1,91 @@ +<.dashboard_layout + current_path={~p"/admin/organizations/#{@org}/devices"} + org={@org} + flash={@flash} +> +
+
+
+

Dispositivos

+

{@org.name}

+
+ +
+ + <%!-- Generated token display --%> +
+

+ Token de Inicialização +

+

+ {@generated_token} +

+

+ Compartilhe este código com o voluntário. Ele expira em 24 horas e só pode ser usado uma vez. +

+ +
+ + <%!-- Devices table --%> +
+
+ <.icon name="hero-device-phone-mobile" class="size-12 text-base-content/20 mx-auto mb-3" /> +

Nenhum dispositivo provisionado.

+

+ Gere um token de inicialização e compartilhe com um voluntário para provisionar um dispositivo. +

+
+ +
+
+
+
+
+

{device.name}

+

+ Provisionado por {device.provisioned_by.name} · + {Calendar.strftime(device.provisioned_at, "%d/%m/%Y %H:%M")} +

+
+
+ +
+
+

Última atividade

+

{time_ago(device.last_seen_at)}

+
+ + + {if device.status == "active", do: "Ativo", else: "Revogado"} + + + +
+
+
+
+ From 2533b477971597aba7802b6e577fe7a9c00caa1c Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Mon, 23 Mar 2026 21:38:16 -0300 Subject: [PATCH 5/5] feat: add Dispositivos to sidebar nav and device LiveView route --- pretex/lib/pretex_web/components/dashboard.ex | 5 +++++ pretex/lib/pretex_web/router.ex | 2 ++ 2 files changed, 7 insertions(+) diff --git a/pretex/lib/pretex_web/components/dashboard.ex b/pretex/lib/pretex_web/components/dashboard.ex index 82760b0..62c1ca1 100644 --- a/pretex/lib/pretex_web/components/dashboard.ex +++ b/pretex/lib/pretex_web/components/dashboard.ex @@ -66,6 +66,11 @@ defmodule PretexWeb.Components.Dashboard do label: "Associações", path: "/admin/organizations/#{org_id}/memberships" }, + %{ + icon: "hero-device-phone-mobile", + label: "Dispositivos", + path: "/admin/organizations/#{org_id}/devices" + }, %{icon: "hero-chart-bar", label: "Relatórios", path: "#", disabled: true}, %{icon: "hero-cog-6-tooth", label: "Configurações", path: "#", disabled: true} ] diff --git a/pretex/lib/pretex_web/router.ex b/pretex/lib/pretex_web/router.ex index f3f04d1..f73db44 100644 --- a/pretex/lib/pretex_web/router.ex +++ b/pretex/lib/pretex_web/router.ex @@ -203,6 +203,8 @@ defmodule PretexWeb.Router do live("/organizations/:org_id/memberships/new", MembershipLive.Index, :new) live("/organizations/:org_id/memberships/:id/edit", MembershipLive.Index, :edit) live("/organizations/:org_id/memberships/:id/grant", MembershipLive.Index, :grant) + + live("/organizations/:org_id/devices", DeviceLive.Index, :index) end end