diff --git a/lib/towerops/accounts.ex b/lib/towerops/accounts.ex
index 74aad1ce..0bd0f8aa 100644
--- a/lib/towerops/accounts.ex
+++ b/lib/towerops/accounts.ex
@@ -5,7 +5,9 @@ defmodule Towerops.Accounts do
import Ecto.Query, warn: false
+ alias Towerops.Accounts.PolicyVersion
alias Towerops.Accounts.User
+ alias Towerops.Accounts.UserConsent
alias Towerops.Accounts.UserCredential
alias Towerops.Accounts.UserNotifier
alias Towerops.Accounts.UserToken
@@ -109,10 +111,22 @@ defmodule Towerops.Accounts do
"""
def register_user(attrs) do
- %User{}
- |> User.registration_changeset(attrs)
- |> Ecto.Changeset.put_change(:confirmed_at, DateTime.utc_now(:second))
- |> Repo.insert()
+ user_changeset =
+ %User{}
+ |> User.registration_changeset(attrs)
+ |> Ecto.Changeset.put_change(:confirmed_at, DateTime.utc_now(:second))
+
+ multi = Ecto.Multi.new()
+ multi = Ecto.Multi.insert(multi, :user, user_changeset)
+
+ # Grant consents if provided
+ multi = grant_consents_on_registration(multi, attrs)
+
+ case Repo.transaction(multi) do
+ {:ok, %{user: user}} -> {:ok, user}
+ {:error, :user, changeset, _} -> {:error, changeset}
+ {:error, _name, _changeset, _} -> {:error, user_changeset}
+ end
end
@doc """
@@ -147,10 +161,37 @@ defmodule Towerops.Accounts do
)
end)
+ # Grant consents if provided
+ multi = grant_consents_on_registration(multi, attrs)
+
case Repo.transaction(multi) do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
{:error, :organization, changeset, _} -> {:error, changeset}
+ {:error, _name, _changeset, _} -> {:error, user_changeset}
+ end
+ end
+
+ defp grant_consents_on_registration(multi, attrs) do
+ # Only grant consents if checkboxes were checked
+ privacy_consent = attrs["privacy_policy_consent"] || attrs[:privacy_policy_consent]
+ terms_consent = attrs["terms_of_service_consent"] || attrs[:terms_of_service_consent]
+
+ multi =
+ if privacy_consent in ["true", true] do
+ Ecto.Multi.run(multi, :privacy_policy_consent, fn _repo, %{user: user} ->
+ grant_consent(user.id, "privacy_policy")
+ end)
+ else
+ multi
+ end
+
+ if terms_consent in ["true", true] do
+ Ecto.Multi.run(multi, :terms_of_service_consent, fn _repo, %{user: user} ->
+ grant_consent(user.id, "terms_of_service")
+ end)
+ else
+ multi
end
end
@@ -734,4 +775,237 @@ defmodule Towerops.Accounts do
defp get_rp_id do
Application.get_env(:towerops, :webauthn_rp_id, "localhost")
end
+
+ ## User Consent
+
+ @doc """
+ Grants consent for a user.
+
+ ## Examples
+
+ iex> grant_consent(user_id, "privacy_policy")
+ {:ok, %UserConsent{}}
+
+ iex> grant_consent(user_id, "invalid_type")
+ {:error, %Ecto.Changeset{}}
+ """
+ def grant_consent(user_id, consent_type) when is_binary(user_id) and is_binary(consent_type) do
+ version = UserConsent.current_version(consent_type)
+ granted_at = DateTime.utc_now(:second)
+
+ %UserConsent{}
+ |> UserConsent.grant_changeset(%{
+ user_id: user_id,
+ consent_type: consent_type,
+ version: version,
+ granted_at: granted_at
+ })
+ |> Repo.insert()
+ end
+
+ @doc """
+ Revokes consent for a user.
+
+ ## Examples
+
+ iex> revoke_consent(consent_id)
+ {:ok, %UserConsent{}}
+
+ iex> revoke_consent("invalid")
+ {:error, :not_found}
+ """
+ def revoke_consent(consent_id) when is_binary(consent_id) do
+ case Repo.get(UserConsent, consent_id) do
+ nil ->
+ {:error, :not_found}
+
+ consent ->
+ consent
+ |> UserConsent.revoke_changeset(%{revoked_at: DateTime.utc_now(:second)})
+ |> Repo.update()
+ end
+ end
+
+ @doc """
+ Lists all consents for a user.
+
+ ## Examples
+
+ iex> list_user_consents(user_id)
+ [%UserConsent{}, ...]
+ """
+ def list_user_consents(user_id) when is_binary(user_id) do
+ UserConsent
+ |> where([c], c.user_id == ^user_id)
+ |> order_by([c], desc: c.granted_at)
+ |> Repo.all()
+ end
+
+ @doc """
+ Gets the active consent for a user and consent type.
+
+ Returns the most recent non-revoked consent for the given type.
+
+ ## Examples
+
+ iex> get_active_consent(user_id, "privacy_policy")
+ %UserConsent{}
+
+ iex> get_active_consent(user_id, "terms_of_service")
+ nil
+ """
+ def get_active_consent(user_id, consent_type) when is_binary(user_id) and is_binary(consent_type) do
+ UserConsent
+ |> where([c], c.user_id == ^user_id)
+ |> where([c], c.consent_type == ^consent_type)
+ |> where([c], is_nil(c.revoked_at))
+ |> order_by([c], desc: c.granted_at)
+ |> limit(1)
+ |> Repo.one()
+ end
+
+ @doc """
+ Checks if a user has granted consent for a specific type.
+
+ ## Examples
+
+ iex> has_consent?(user_id, "privacy_policy")
+ true
+
+ iex> has_consent?(user_id, "terms_of_service")
+ false
+ """
+ def has_consent?(user_id, consent_type) when is_binary(user_id) and is_binary(consent_type) do
+ get_active_consent(user_id, consent_type) != nil
+ end
+
+ @doc """
+ Checks if a user has granted all required consents.
+
+ ## Examples
+
+ iex> has_all_required_consents?(user_id)
+ true
+ """
+ def has_all_required_consents?(user_id) when is_binary(user_id) do
+ Enum.all?(UserConsent.consent_types(), fn consent_type ->
+ has_consent?(user_id, consent_type)
+ end)
+ end
+
+ ## Policy Version Management
+
+ @doc """
+ Creates a new policy version.
+
+ ## Examples
+
+ iex> create_policy_version(%{policy_type: "privacy_policy", version: "2.0", ...})
+ {:ok, %PolicyVersion{}}
+
+ iex> create_policy_version(%{policy_type: "invalid", ...})
+ {:error, %Ecto.Changeset{}}
+ """
+ def create_policy_version(attrs) do
+ %PolicyVersion{}
+ |> PolicyVersion.changeset(attrs)
+ |> Repo.insert()
+ end
+
+ @doc """
+ Gets the current (most recent) version of a policy.
+
+ ## Examples
+
+ iex> get_current_policy_version("privacy_policy")
+ %PolicyVersion{}
+
+ iex> get_current_policy_version("terms_of_service")
+ nil
+ """
+ def get_current_policy_version(policy_type) when is_binary(policy_type) do
+ PolicyVersion
+ |> where([p], p.policy_type == ^policy_type)
+ |> where([p], p.effective_date <= ^DateTime.utc_now(:second))
+ |> order_by([p], desc: p.effective_date)
+ |> limit(1)
+ |> Repo.one()
+ end
+
+ @doc """
+ Gets a specific version of a policy.
+
+ ## Examples
+
+ iex> get_policy_version("privacy_policy", "1.0")
+ %PolicyVersion{}
+
+ iex> get_policy_version("privacy_policy", "999.0")
+ nil
+ """
+ def get_policy_version(policy_type, version) when is_binary(policy_type) and is_binary(version) do
+ PolicyVersion
+ |> where([p], p.policy_type == ^policy_type)
+ |> where([p], p.version == ^version)
+ |> Repo.one()
+ end
+
+ @doc """
+ Lists all versions of a policy.
+
+ ## Examples
+
+ iex> list_policy_versions("privacy_policy")
+ [%PolicyVersion{}, ...]
+ """
+ def list_policy_versions(policy_type) when is_binary(policy_type) do
+ PolicyVersion
+ |> where([p], p.policy_type == ^policy_type)
+ |> order_by([p], desc: p.effective_date)
+ |> Repo.all()
+ end
+
+ @doc """
+ Checks if a user needs to re-consent to a policy.
+
+ Returns true if the current policy version is newer than the user's consent.
+
+ ## Examples
+
+ iex> needs_reconsent?(user_id, "privacy_policy")
+ false
+
+ iex> needs_reconsent?(user_id, "terms_of_service")
+ true
+ """
+ def needs_reconsent?(user_id, policy_type) when is_binary(user_id) and is_binary(policy_type) do
+ current_policy = get_current_policy_version(policy_type)
+ user_consent = get_active_consent(user_id, policy_type)
+
+ cond do
+ is_nil(current_policy) -> false
+ is_nil(user_consent) -> true
+ user_consent.version != current_policy.version -> true
+ true -> false
+ end
+ end
+
+ @doc """
+ Checks if a user needs to re-consent to any policies.
+
+ Returns a list of policy types that require re-consent.
+
+ ## Examples
+
+ iex> policies_needing_reconsent(user_id)
+ []
+
+ iex> policies_needing_reconsent(user_id)
+ ["privacy_policy", "terms_of_service"]
+ """
+ def policies_needing_reconsent(user_id) when is_binary(user_id) do
+ Enum.filter(UserConsent.consent_types(), fn policy_type ->
+ needs_reconsent?(user_id, policy_type)
+ end)
+ end
end
diff --git a/lib/towerops/accounts/policy_version.ex b/lib/towerops/accounts/policy_version.ex
new file mode 100644
index 00000000..9c1f24eb
--- /dev/null
+++ b/lib/towerops/accounts/policy_version.ex
@@ -0,0 +1,42 @@
+defmodule Towerops.Accounts.PolicyVersion do
+ @moduledoc """
+ Policy version schema for tracking versions of privacy policy and terms of service.
+
+ Stores the full text of each policy version with effective dates to maintain
+ a complete audit trail of policy changes for GDPR compliance.
+ """
+ use Ecto.Schema
+
+ import Ecto.Changeset
+
+ @primary_key {:id, :binary_id, autogenerate: true}
+ @foreign_key_type :binary_id
+ schema "policy_versions" do
+ field :policy_type, :string
+ field :version, :string
+ field :content, :string
+ field :effective_date, :utc_datetime
+
+ timestamps(type: :utc_datetime)
+ end
+
+ @policy_types ~w(privacy_policy terms_of_service)
+
+ @doc """
+ Returns the list of valid policy types.
+ """
+ def policy_types, do: @policy_types
+
+ @doc """
+ Changeset for creating a new policy version.
+ """
+ def changeset(policy_version \\ %__MODULE__{}, attrs) do
+ policy_version
+ |> cast(attrs, [:policy_type, :version, :content, :effective_date])
+ |> validate_required([:policy_type, :version, :content, :effective_date])
+ |> validate_inclusion(:policy_type, @policy_types)
+ |> validate_length(:version, min: 1, max: 20)
+ |> validate_length(:content, min: 10)
+ |> unique_constraint([:policy_type, :version])
+ end
+end
diff --git a/lib/towerops/accounts/user.ex b/lib/towerops/accounts/user.ex
index 7225661d..a8d58820 100644
--- a/lib/towerops/accounts/user.ex
+++ b/lib/towerops/accounts/user.ex
@@ -30,6 +30,8 @@ defmodule Towerops.Accounts.User do
field :timezone, :string
field :totp_secret, :binary, redact: true
field :totp_verified_at, :utc_datetime, virtual: true
+ field :privacy_policy_consent, :boolean, virtual: true
+ field :terms_of_service_consent, :boolean, virtual: true
has_many :memberships, Membership
has_many :organizations, through: [:memberships, :organization]
@@ -126,9 +128,20 @@ defmodule Towerops.Accounts.User do
"""
def registration_changeset(user, attrs, opts \\ []) do
user
- |> cast(attrs, [:email, :password])
+ |> cast(attrs, [:email, :password, :privacy_policy_consent, :terms_of_service_consent])
|> validate_email_for_registration(opts)
|> validate_password(opts)
+ |> validate_consent()
+ end
+
+ defp validate_consent(changeset) do
+ changeset
+ |> validate_acceptance(:privacy_policy_consent,
+ message: "must be accepted to create an account"
+ )
+ |> validate_acceptance(:terms_of_service_consent,
+ message: "must be accepted to create an account"
+ )
end
defp validate_email_for_registration(changeset, opts) do
diff --git a/lib/towerops/accounts/user_consent.ex b/lib/towerops/accounts/user_consent.ex
new file mode 100644
index 00000000..27595478
--- /dev/null
+++ b/lib/towerops/accounts/user_consent.ex
@@ -0,0 +1,73 @@
+defmodule Towerops.Accounts.UserConsent do
+ @moduledoc """
+ User consent schema for tracking consent to privacy policy and terms of service.
+
+ Stores consent records with versioning to comply with GDPR and privacy regulations.
+ Allows users to withdraw consent at any time.
+ """
+ use Ecto.Schema
+
+ import Ecto.Changeset
+
+ alias Towerops.Accounts.User
+
+ @primary_key {:id, :binary_id, autogenerate: true}
+ @foreign_key_type :binary_id
+ schema "user_consents" do
+ belongs_to :user, User
+
+ field :consent_type, :string
+ field :version, :string
+ field :granted_at, :utc_datetime
+ field :revoked_at, :utc_datetime
+
+ timestamps(type: :utc_datetime)
+ end
+
+ @consent_types ~w(privacy_policy terms_of_service)
+ @current_privacy_policy_version "1.0"
+ @current_terms_of_service_version "1.0"
+
+ @doc """
+ Returns the list of valid consent types.
+ """
+ def consent_types, do: @consent_types
+
+ @doc """
+ Returns the current version for a given consent type.
+ """
+ def current_version("privacy_policy"), do: @current_privacy_policy_version
+ def current_version("terms_of_service"), do: @current_terms_of_service_version
+
+ @doc """
+ Changeset for granting consent.
+ """
+ def grant_changeset(consent \\ %__MODULE__{}, attrs) do
+ consent
+ |> cast(attrs, [:user_id, :consent_type, :version, :granted_at])
+ |> validate_required([:user_id, :consent_type, :version, :granted_at])
+ |> validate_inclusion(:consent_type, @consent_types)
+ |> foreign_key_constraint(:user_id)
+ |> unique_constraint([:user_id, :consent_type, :version],
+ name: :user_consents_user_id_consent_type_version_index
+ )
+ end
+
+ @doc """
+ Changeset for revoking consent.
+ """
+ def revoke_changeset(consent, attrs \\ %{}) do
+ consent
+ |> cast(attrs, [:revoked_at])
+ |> validate_required([:revoked_at])
+ |> validate_not_already_revoked()
+ end
+
+ defp validate_not_already_revoked(changeset) do
+ if get_field(changeset, :revoked_at) do
+ add_error(changeset, :revoked_at, "consent already revoked")
+ else
+ changeset
+ end
+ end
+end
diff --git a/lib/towerops_web/components/consent_prompt.ex b/lib/towerops_web/components/consent_prompt.ex
new file mode 100644
index 00000000..95ce39c0
--- /dev/null
+++ b/lib/towerops_web/components/consent_prompt.ex
@@ -0,0 +1,118 @@
+defmodule ToweropsWeb.Components.ConsentPrompt do
+ @moduledoc """
+ LiveView component for prompting users to re-consent when policies are updated.
+
+ This component displays a modal that users cannot dismiss until they accept
+ the updated privacy policy and/or terms of service.
+ """
+ use Phoenix.Component
+
+ import ToweropsWeb.CoreComponents
+
+ attr :user_id, :string, required: true
+ attr :policies, :list, required: true
+
+ def consent_modal(assigns) do
+ ~H"""
+ <%= if @policies != [] do %>
+
+
+
+
+
+
+
+
+
+ <.icon
+ name="hero-information-circle"
+ class="h-6 w-6 text-blue-600 dark:text-blue-400"
+ />
+
+
+
+ Updated Policies - Action Required
+
+
+
+ We've updated our policies. Please review and accept the changes to continue using Towerops.
+
+
+
+ <%= for policy_type <- @policies do %>
+
+
+
+
+
+
+ I agree to the updated
+ <.link
+ href={policy_link(policy_type)}
+ target="_blank"
+ class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
+ >
+ {policy_name(policy_type)}
+
+ *
+
+
+
+ <% end %>
+
+
+
+
+ By accepting, you acknowledge that you have read and agree to the updated policies.
+ You can review your consent history at any time from your account settings.
+
+
+
+
+
+
+
+ Accept and Continue
+
+
+
+
+
+ <% end %>
+ """
+ end
+
+ defp policy_name("privacy_policy"), do: "Privacy Policy"
+ defp policy_name("terms_of_service"), do: "Terms of Service"
+ defp policy_name(_), do: "Policy"
+
+ defp policy_link("privacy_policy"), do: "/privacy"
+ defp policy_link("terms_of_service"), do: "/terms"
+ defp policy_link(_), do: "/"
+end
diff --git a/lib/towerops_web/components/layouts.ex b/lib/towerops_web/components/layouts.ex
index 8e3209b9..14adc2d1 100644
--- a/lib/towerops_web/components/layouts.ex
+++ b/lib/towerops_web/components/layouts.ex
@@ -5,6 +5,7 @@ defmodule ToweropsWeb.Layouts do
"""
use ToweropsWeb, :html
+ alias ToweropsWeb.Components.ConsentPrompt
alias ToweropsWeb.Components.CookieConsent
require Logger
@@ -123,10 +124,14 @@ defmodule ToweropsWeb.Layouts do
current_organization = assigns[:current_scope] && assigns.current_scope.organization
timezone = (assigns[:current_scope] && assigns.current_scope.timezone) || "UTC"
+ # Get policies needing consent from assigns (set in CheckPolicyConsent plug)
+ policies_needing_consent = Map.get(assigns, :policies_needing_consent, [])
+
assigns =
assigns
|> Map.put(:current_organization, current_organization)
|> Map.put(:timezone, timezone)
+ |> Map.put(:policies_needing_consent, policies_needing_consent)
~H"""
@@ -428,6 +433,12 @@ defmodule ToweropsWeb.Layouts do
<.flash_group flash={@flash} />
+ <%= if @current_scope && @current_scope.user do %>
+
+ <% end %>
"""
end
diff --git a/lib/towerops_web/controllers/page_html/privacy.html.heex b/lib/towerops_web/controllers/page_html/privacy.html.heex
index 30ddeb4b..58525e02 100644
--- a/lib/towerops_web/controllers/page_html/privacy.html.heex
+++ b/lib/towerops_web/controllers/page_html/privacy.html.heex
@@ -3,7 +3,7 @@
Privacy Policy
- Last updated: {Date.utc_today() |> Calendar.strftime("%B %d, %Y")}
+ Version 1.0 | Effective Date: January 29, 2026
diff --git a/lib/towerops_web/controllers/page_html/terms.html.heex b/lib/towerops_web/controllers/page_html/terms.html.heex
index 16bf7427..2b711a1c 100644
--- a/lib/towerops_web/controllers/page_html/terms.html.heex
+++ b/lib/towerops_web/controllers/page_html/terms.html.heex
@@ -3,7 +3,7 @@
Terms of Service
- Last updated: {Date.utc_today() |> Calendar.strftime("%B %d, %Y")}
+ Version 1.0 | Effective Date: January 29, 2026
diff --git a/lib/towerops_web/controllers/user_registration_html/new.html.heex b/lib/towerops_web/controllers/user_registration_html/new.html.heex
index 43d19642..915619da 100644
--- a/lib/towerops_web/controllers/user_registration_html/new.html.heex
+++ b/lib/towerops_web/controllers/user_registration_html/new.html.heex
@@ -76,7 +76,59 @@
/>
<% end %>
- <.button phx-disable-with="Creating account..." class="w-full" variant="primary">
+
+
+
+
+
+
+
+ I agree to the
+ <.link
+ href={~p"/privacy"}
+ target="_blank"
+ class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
+ >
+ Privacy Policy
+
+ *
+
+
+
+
+
+
+
+
+
+
+ I agree to the
+ <.link
+ href={~p"/terms"}
+ target="_blank"
+ class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
+ >
+ Terms of Service
+
+ *
+
+
+
+
+
+ <.button phx-disable-with="Creating account..." class="w-full mt-6" variant="primary">
{if @invitation, do: "Accept invitation and create account", else: "Create an account"}
diff --git a/lib/towerops_web/live/account_live/my_data.ex b/lib/towerops_web/live/account_live/my_data.ex
index 3b5a5d7e..c486d910 100644
--- a/lib/towerops_web/live/account_live/my_data.ex
+++ b/lib/towerops_web/live/account_live/my_data.ex
@@ -11,6 +11,24 @@ defmodule ToweropsWeb.AccountLive.MyData do
alias Towerops.Devices
alias Towerops.Organizations
+ @impl true
+ def handle_event("revoke_consent", %{"consent-id" => consent_id}, socket) do
+ case Accounts.revoke_consent(consent_id) do
+ {:ok, _consent} ->
+ user = socket.assigns.current_scope.user
+
+ socket =
+ socket
+ |> put_flash(:info, "Consent revoked successfully.")
+ |> assign(:user_data, Map.put(socket.assigns.user_data, :consents, get_user_consents(user)))
+
+ {:noreply, socket}
+
+ {:error, _} ->
+ {:noreply, put_flash(socket, :error, "Failed to revoke consent.")}
+ end
+ end
+
@impl true
def mount(_params, _session, socket) do
user = socket.assigns.current_scope.user
@@ -24,6 +42,7 @@ defmodule ToweropsWeb.AccountLive.MyData do
user_data = %{
profile: get_user_profile(user),
credentials: get_user_credentials(user),
+ consents: get_user_consents(user),
organizations: if(is_owner, do: organizations, else: []),
devices: if(is_owner, do: get_user_devices(user), else: []),
alerts: if(is_owner, do: get_user_alerts(user), else: []),
@@ -156,6 +175,81 @@ defmodule ToweropsWeb.AccountLive.MyData do
<% end %>
+
+
+
+
+
+ Consent Records
+
+
+ Your consent to our Privacy Policy and Terms of Service
+
+
+
+ <%= if Enum.empty?(@user_data.consents) do %>
+
+ No consent records found.
+
+ <% else %>
+
+ <%= for consent <- @user_data.consents do %>
+
+
+
+
Type
+
+ {String.replace(consent.consent_type, "_", " ") |> String.capitalize()}
+
+
+
+
Version
+ {consent.version}
+
+
+
+ Granted
+
+
+ {Calendar.strftime(consent.granted_at, "%B %d, %Y at %I:%M %p")}
+
+
+
+
+ Status
+
+
+ <%= if consent.revoked_at do %>
+
+ Revoked {Calendar.strftime(consent.revoked_at, "%B %d, %Y")}
+
+ <% else %>
+
+ Active
+
+ <% end %>
+
+
+
+ <%= if !consent.revoked_at do %>
+
+
+ Revoke Consent
+
+
+ <% end %>
+
+ <% end %>
+
+ <% end %>
+
+
<%= if @is_owner do %>
@@ -517,6 +611,11 @@ defmodule ToweropsWeb.AccountLive.MyData do
Admin.list_audit_logs_for_user(user.id)
end
+ # Helper function to get user's consent records
+ defp get_user_consents(user) do
+ Accounts.list_user_consents(user.id)
+ end
+
# Helper function for alert status badge styling
defp alert_status_class(alert) do
cond do
diff --git a/lib/towerops_web/plugs/check_policy_consent.ex b/lib/towerops_web/plugs/check_policy_consent.ex
new file mode 100644
index 00000000..cf443739
--- /dev/null
+++ b/lib/towerops_web/plugs/check_policy_consent.ex
@@ -0,0 +1,29 @@
+defmodule ToweropsWeb.Plugs.CheckPolicyConsent do
+ @moduledoc """
+ Plug to check if authenticated users need to re-consent to updated policies.
+
+ This plug checks if there are any policy versions newer than the user's
+ last consent and stores the list of policies requiring re-consent in assigns.
+
+ The component can then display a modal prompting the user to accept the
+ updated policies before continuing.
+ """
+ import Plug.Conn
+
+ alias Towerops.Accounts
+
+ def init(opts), do: opts
+
+ def call(conn, _opts) do
+ case conn.assigns[:current_user] do
+ nil ->
+ # Not authenticated, no need to check consent
+ assign(conn, :policies_needing_consent, [])
+
+ user ->
+ # Check if user needs to re-consent to any policies
+ policies_needing_consent = Accounts.policies_needing_reconsent(user.id)
+ assign(conn, :policies_needing_consent, policies_needing_consent)
+ end
+ end
+end
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex
index 42e9b16e..4a8a5bb8 100644
--- a/lib/towerops_web/router.ex
+++ b/lib/towerops_web/router.ex
@@ -20,6 +20,7 @@ defmodule ToweropsWeb.Router do
plug :fetch_current_scope_for_user
plug :store_return_to_for_liveview
plug ToweropsWeb.Plugs.DetectEUUser
+ plug ToweropsWeb.Plugs.CheckPolicyConsent
end
pipeline :api do
@@ -192,7 +193,8 @@ defmodule ToweropsWeb.Router do
{ToweropsWeb.UserAuth, :load_cookie_consent},
{ToweropsWeb.UserAuth, :require_authenticated_user},
{ToweropsWeb.UserAuth, :require_totp_enrollment},
- {ToweropsWeb.UserAuth, :require_superuser}
+ {ToweropsWeb.UserAuth, :require_superuser},
+ {ToweropsWeb.UserAuth, :handle_policy_consent}
] do
scope "/admin", ToweropsWeb.Admin do
pipe_through [:browser, :require_authenticated_user, :require_superuser]
@@ -210,7 +212,8 @@ defmodule ToweropsWeb.Router do
{ToweropsWeb.UserAuth, :load_cookie_consent},
{ToweropsWeb.UserAuth, :require_authenticated_user},
{ToweropsWeb.UserAuth, :require_totp_enrollment},
- {ToweropsWeb.UserAuth, :require_sudo_mode}
+ {ToweropsWeb.UserAuth, :require_sudo_mode},
+ {ToweropsWeb.UserAuth, :handle_policy_consent}
] do
scope "/", ToweropsWeb do
pipe_through [:browser, :require_authenticated_user]
@@ -237,7 +240,8 @@ defmodule ToweropsWeb.Router do
on_mount: [
{ToweropsWeb.UserAuth, :load_cookie_consent},
{ToweropsWeb.UserAuth, :require_authenticated_user},
- {ToweropsWeb.UserAuth, :require_totp_enrollment}
+ {ToweropsWeb.UserAuth, :require_totp_enrollment},
+ {ToweropsWeb.UserAuth, :handle_policy_consent}
] do
scope "/", ToweropsWeb do
pipe_through [:browser, :require_authenticated_user]
@@ -253,7 +257,8 @@ defmodule ToweropsWeb.Router do
{ToweropsWeb.UserAuth, :load_cookie_consent},
{ToweropsWeb.UserAuth, :require_authenticated_user},
{ToweropsWeb.UserAuth, :require_totp_enrollment},
- {ToweropsWeb.UserAuth, :load_current_organization}
+ {ToweropsWeb.UserAuth, :load_current_organization},
+ {ToweropsWeb.UserAuth, :handle_policy_consent}
] do
scope "/orgs/:org_slug", ToweropsWeb do
pipe_through [:browser, :require_authenticated_user, :load_current_organization]
@@ -268,7 +273,8 @@ defmodule ToweropsWeb.Router do
{ToweropsWeb.UserAuth, :load_cookie_consent},
{ToweropsWeb.UserAuth, :require_authenticated_user},
{ToweropsWeb.UserAuth, :require_totp_enrollment},
- {ToweropsWeb.UserAuth, :load_default_organization}
+ {ToweropsWeb.UserAuth, :load_default_organization},
+ {ToweropsWeb.UserAuth, :handle_policy_consent}
] do
scope "/", ToweropsWeb do
pipe_through [:browser, :require_authenticated_user]
diff --git a/lib/towerops_web/user_auth.ex b/lib/towerops_web/user_auth.ex
index 9cd6a4a8..a9ed3645 100644
--- a/lib/towerops_web/user_auth.ex
+++ b/lib/towerops_web/user_auth.ex
@@ -552,6 +552,33 @@ defmodule ToweropsWeb.UserAuth do
{:cont, socket}
end
+ def on_mount(:handle_policy_consent, _params, _session, socket) do
+ # Attach LiveView event handler for accepting updated policies
+ socket =
+ LiveView.attach_hook(socket, :policy_consent_handler, :handle_event, fn
+ "accept_updated_policies", %{"user-id" => user_id}, socket ->
+ # Grant consent for all policies that need re-consent
+ policies_needing_consent = Map.get(socket.assigns, :policies_needing_consent, [])
+
+ Enum.each(policies_needing_consent, fn policy_type ->
+ Accounts.grant_consent(user_id, policy_type)
+ end)
+
+ # Clear the policies_needing_consent list and show success message
+ socket =
+ socket
+ |> Phoenix.Component.assign(:policies_needing_consent, [])
+ |> LiveView.put_flash(:info, "Thank you for accepting the updated policies.")
+
+ {:halt, socket}
+
+ _event, _params, socket ->
+ {:cont, socket}
+ end)
+
+ {:cont, socket}
+ end
+
defp load_organization_for_user(socket, session, user) do
org_id = session["current_organization_id"]
organization = find_user_organization(org_id, user.id)
diff --git a/priv/repo/migrations/20260129164956_create_user_consents.exs b/priv/repo/migrations/20260129164956_create_user_consents.exs
new file mode 100644
index 00000000..548e5ede
--- /dev/null
+++ b/priv/repo/migrations/20260129164956_create_user_consents.exs
@@ -0,0 +1,20 @@
+defmodule Towerops.Repo.Migrations.CreateUserConsents do
+ use Ecto.Migration
+
+ def change do
+ create table(:user_consents, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
+ add :consent_type, :string, null: false
+ add :version, :string, null: false
+ add :granted_at, :utc_datetime, null: false
+ add :revoked_at, :utc_datetime
+
+ timestamps(type: :utc_datetime)
+ end
+
+ create index(:user_consents, [:user_id])
+ create index(:user_consents, [:consent_type])
+ create index(:user_consents, [:user_id, :consent_type, :version])
+ end
+end
diff --git a/priv/repo/migrations/20260129170433_create_policy_versions.exs b/priv/repo/migrations/20260129170433_create_policy_versions.exs
new file mode 100644
index 00000000..b9388124
--- /dev/null
+++ b/priv/repo/migrations/20260129170433_create_policy_versions.exs
@@ -0,0 +1,19 @@
+defmodule Towerops.Repo.Migrations.CreatePolicyVersions do
+ use Ecto.Migration
+
+ def change do
+ create table(:policy_versions, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :policy_type, :string, null: false
+ add :version, :string, null: false
+ add :content, :text, null: false
+ add :effective_date, :utc_datetime, null: false
+
+ timestamps(type: :utc_datetime)
+ end
+
+ create index(:policy_versions, [:policy_type])
+ create index(:policy_versions, [:effective_date])
+ create unique_index(:policy_versions, [:policy_type, :version])
+ end
+end
diff --git a/priv/static/changelog.txt b/priv/static/changelog.txt
index 844b1503..605ad2bc 100644
--- a/priv/static/changelog.txt
+++ b/priv/static/changelog.txt
@@ -2,6 +2,10 @@ Devices Working
* Mikrotik RouterOS
* Ubiquiti AC, LTU, AirFiber
+2026-01-29
+* GDPR: Consent management system with explicit consent checkboxes and withdrawal
+* Bug fix: sensor data storage
+
2026-01-28
* Major SNMP Discovery overhaul and improvements
* GDPR: Cookie consent banner for EU/EEA users
diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex
index 3afca93f..13b8377f 100644
--- a/test/support/fixtures/accounts_fixtures.ex
+++ b/test/support/fixtures/accounts_fixtures.ex
@@ -15,7 +15,9 @@ defmodule Towerops.AccountsFixtures do
def valid_user_attributes(attrs \\ %{}) do
Enum.into(attrs, %{
email: unique_user_email(),
- password: valid_user_password()
+ password: valid_user_password(),
+ privacy_policy_consent: true,
+ terms_of_service_consent: true
})
end
diff --git a/test/towerops_web/controllers/user_registration_controller_test.exs b/test/towerops_web/controllers/user_registration_controller_test.exs
index 2abee35c..4579bca4 100644
--- a/test/towerops_web/controllers/user_registration_controller_test.exs
+++ b/test/towerops_web/controllers/user_registration_controller_test.exs
@@ -67,7 +67,9 @@ defmodule ToweropsWeb.UserRegistrationControllerTest do
"user" => %{
"email" => email,
"password" => valid_user_password(),
- "organization_name" => "Acme Corp"
+ "organization_name" => "Acme Corp",
+ "privacy_policy_consent" => "true",
+ "terms_of_service_consent" => "true"
}
})