gdpr consent tracking
This commit is contained in:
parent
e76bd1fd91
commit
c0736d4c2e
18 changed files with 806 additions and 15 deletions
|
|
@ -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
|
||||
|
|
|
|||
42
lib/towerops/accounts/policy_version.ex
Normal file
42
lib/towerops/accounts/policy_version.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
73
lib/towerops/accounts/user_consent.ex
Normal file
73
lib/towerops/accounts/user_consent.ex
Normal 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
|
||||
118
lib/towerops_web/components/consent_prompt.ex
Normal file
118
lib/towerops_web/components/consent_prompt.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
29
lib/towerops_web/plugs/check_policy_consent.ex
Normal file
29
lib/towerops_web/plugs/check_policy_consent.ex
Normal 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
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
20
priv/repo/migrations/20260129164956_create_user_consents.exs
Normal file
20
priv/repo/migrations/20260129164956_create_user_consents.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue