From c0736d4c2ed052f38ce1723a4b6d5404551754a0 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 29 Jan 2026 11:12:35 -0600 Subject: [PATCH] gdpr consent tracking --- lib/towerops/accounts.ex | 282 +++++++++++++++++- lib/towerops/accounts/policy_version.ex | 42 +++ lib/towerops/accounts/user.ex | 15 +- lib/towerops/accounts/user_consent.ex | 73 +++++ lib/towerops_web/components/consent_prompt.ex | 118 ++++++++ lib/towerops_web/components/layouts.ex | 11 + .../controllers/page_html/privacy.html.heex | 2 +- .../controllers/page_html/terms.html.heex | 2 +- .../user_registration_html/new.html.heex | 54 +++- lib/towerops_web/live/account_live/my_data.ex | 99 ++++++ .../plugs/check_policy_consent.ex | 29 ++ lib/towerops_web/router.ex | 16 +- lib/towerops_web/user_auth.ex | 27 ++ .../20260129164956_create_user_consents.exs | 20 ++ .../20260129170433_create_policy_versions.exs | 19 ++ priv/static/changelog.txt | 4 + test/support/fixtures/accounts_fixtures.ex | 4 +- .../user_registration_controller_test.exs | 4 +- 18 files changed, 806 insertions(+), 15 deletions(-) create mode 100644 lib/towerops/accounts/policy_version.ex create mode 100644 lib/towerops/accounts/user_consent.ex create mode 100644 lib/towerops_web/components/consent_prompt.ex create mode 100644 lib/towerops_web/plugs/check_policy_consent.ex create mode 100644 priv/repo/migrations/20260129164956_create_user_consents.exs create mode 100644 priv/repo/migrations/20260129170433_create_policy_versions.exs 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 %> + + <% 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"> +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + <.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 %> +
+ +
+ <% 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" } })