gdpr consent tracking

This commit is contained in:
Graham McIntire 2026-01-29 11:12:35 -06:00
parent e76bd1fd91
commit c0736d4c2e
18 changed files with 806 additions and 15 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 %>
<div
id="consent-modal"
class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="consent-modal-title"
role="dialog"
aria-modal="true"
>
<div class="flex min-h-screen items-center justify-center p-4">
<!-- Backdrop -->
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
<!-- Modal panel -->
<div class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
<div class="sm:flex sm:items-start">
<div class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900 sm:mx-0 sm:h-10 sm:w-10">
<.icon
name="hero-information-circle"
class="h-6 w-6 text-blue-600 dark:text-blue-400"
/>
</div>
<div class="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left w-full">
<h3
class="text-lg font-semibold leading-6 text-gray-900 dark:text-white"
id="consent-modal-title"
>
Updated Policies - Action Required
</h3>
<div class="mt-4 space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
We've updated our policies. Please review and accept the changes to continue using Towerops.
</p>
<div class="space-y-3">
<%= for policy_type <- @policies do %>
<div class="flex items-start">
<div class="flex items-center h-5">
<input
type="checkbox"
id={"consent-#{policy_type}"}
name={"consent[#{policy_type}]"}
phx-click="toggle_consent"
phx-value-policy={policy_type}
required
class="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:checked:bg-blue-500 dark:checked:border-blue-500"
/>
</div>
<div class="ml-3 text-sm">
<label
for={"consent-#{policy_type}"}
class="text-gray-700 dark:text-gray-300"
>
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)}
</.link>
<span class="text-red-600 dark:text-red-400">*</span>
</label>
</div>
</div>
<% end %>
</div>
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<p class="text-xs text-gray-500 dark:text-gray-400">
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.
</p>
</div>
</div>
</div>
</div>
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button
type="button"
phx-click="accept_updated_policies"
phx-value-user-id={@user_id}
class="inline-flex w-full justify-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 sm:ml-3 sm:w-auto disabled:opacity-50 disabled:cursor-not-allowed"
id="accept-policies-button"
>
Accept and Continue
</button>
</div>
</div>
</div>
</div>
<% 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

View file

@ -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"""
<div class="flex min-h-screen flex-col bg-gray-50 dark:bg-gray-950">
@ -428,6 +433,12 @@ defmodule ToweropsWeb.Layouts do
<.flash_group flash={@flash} />
<CookieConsent.banner requires_consent={@requires_cookie_consent} />
<%= if @current_scope && @current_scope.user do %>
<ConsentPrompt.consent_modal
user_id={@current_scope.user.id}
policies={@policies_needing_consent}
/>
<% end %>
"""
end

View file

@ -3,7 +3,7 @@
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Privacy Policy</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Last updated: {Date.utc_today() |> Calendar.strftime("%B %d, %Y")}
Version 1.0 | Effective Date: January 29, 2026
</p>
</div>

View file

@ -3,7 +3,7 @@
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Terms of Service</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Last updated: {Date.utc_today() |> Calendar.strftime("%B %d, %Y")}
Version 1.0 | Effective Date: January 29, 2026
</p>
</div>

View file

@ -76,7 +76,59 @@
/>
<% end %>
<.button phx-disable-with="Creating account..." class="w-full" variant="primary">
<div class="space-y-3 mt-6">
<div class="flex items-start">
<div class="flex items-center h-5">
<input
id="privacy_policy_consent"
name="user[privacy_policy_consent]"
type="checkbox"
required
class="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:checked:bg-blue-500 dark:checked:border-blue-500"
/>
</div>
<div class="ml-3 text-sm">
<label for="privacy_policy_consent" class="text-zinc-700 dark:text-zinc-300">
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
</.link>
<span class="text-red-600 dark:text-red-400">*</span>
</label>
</div>
</div>
<div class="flex items-start">
<div class="flex items-center h-5">
<input
id="terms_of_service_consent"
name="user[terms_of_service_consent]"
type="checkbox"
required
class="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:checked:bg-blue-500 dark:checked:border-blue-500"
/>
</div>
<div class="ml-3 text-sm">
<label for="terms_of_service_consent" class="text-zinc-700 dark:text-zinc-300">
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
</.link>
<span class="text-red-600 dark:text-red-400">*</span>
</label>
</div>
</div>
</div>
<.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"}
</.button>
</.form>

View file

@ -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 %>
</div>
</section>
<!-- Consent Records -->
<section class="bg-white dark:bg-gray-900 shadow rounded-lg overflow-hidden">
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
Consent Records
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Your consent to our Privacy Policy and Terms of Service
</p>
</div>
<div class="px-4 py-5 sm:p-6">
<%= if Enum.empty?(@user_data.consents) do %>
<p class="text-sm text-gray-500 dark:text-gray-400">
No consent records found.
</p>
<% else %>
<div class="space-y-4">
<%= for consent <- @user_data.consents do %>
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<dl class="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Type</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{String.replace(consent.consent_type, "_", " ") |> String.capitalize()}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Version</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{consent.version}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
Granted
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{Calendar.strftime(consent.granted_at, "%B %d, %Y at %I:%M %p")}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
Status
</dt>
<dd class="mt-1 text-sm">
<%= if consent.revoked_at do %>
<span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 inset-ring-1 inset-ring-red-600/20 dark:bg-red-500/10 dark:text-red-400 dark:inset-ring-red-500/20">
Revoked {Calendar.strftime(consent.revoked_at, "%B %d, %Y")}
</span>
<% else %>
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 inset-ring-1 inset-ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:inset-ring-green-500/20">
Active
</span>
<% end %>
</dd>
</div>
</dl>
<%= if !consent.revoked_at do %>
<div class="mt-4">
<button
type="button"
phx-click="revoke_consent"
phx-value-consent-id={consent.id}
data-confirm="Are you sure you want to revoke this consent? This may limit your ability to use certain features."
class="text-sm font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
Revoke Consent
</button>
</div>
<% end %>
</div>
<% end %>
</div>
<% end %>
</div>
</section>
<%= if @is_owner do %>
<!-- Organizations -->
@ -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

View file

@ -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

View file

@ -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]

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"
}
})