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/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/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/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/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} +> +
{@org.name}
++ 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. +
+ +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)}
+