PhoenixKitLegal

ElixirLicense: MIT

Legal compliance module for PhoenixKit. GDPR, CCPA, LGPD, and PIPEDA compliant legal page generation, cookie consent widget, and consent audit logging.

Features

Installation

Add phoenix_kit_legal to your dependencies in mix.exs:

def deps do
[
{:phoenix_kit_legal, "~> 0.1"}
]
end

Then fetch dependencies:

mix deps.get

Note: For development or if not yet published to Hex, you can use:

{:phoenix_kit_legal, github: "BeamLabEU/phoenix_kit_legal"}

Automated setup

Run the install task to patch your app automatically:

mix phoenix_kit_legal.install

This task is idempotent — safe to run multiple times. It performs three steps:

StepWhat it does
lib/**/endpoint.exAdds Plug.Static at /phoenix_kit_legal to serve the consent JS
assets/css/app.cssAdds @source "../../deps/phoenix_kit_legal" for Tailwind class scanning
assets/js/app.jsAdds import "../../deps/phoenix_kit_legal/priv/static/assets/phoenix_kit_consent.js"

Then it prints the remaining manual steps (migration, JS hook, router scope, component).

Manual steps after install

1. Copy and run the migration:

cp deps/phoenix_kit_legal/priv/migrations/add_phoenix_kit_consent_logs.exs \
priv/repo/migrations/$(date +%Y%m%d%H%M%S)_add_phoenix_kit_consent_logs.exs
# Edit: rename MyApp.Repo to your repo module name
mix ecto.migrate

2. Wire up the JS hook in assets/js/app.js:

// Side-effect import — IIFE registers window.PhoenixKitHooks.CookieConsent
import "../../deps/phoenix_kit_legal/priv/static/assets/phoenix_kit_consent.js"
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { ...Hooks, ...window.PhoenixKitHooks },
params: {_csrf_token: csrfToken}
})

3. Add the router scope in router.ex:

scope "/admin/settings", PhoenixKitWeb.Live.Modules.Legal do
live "/legal", Settings, :index
end

4. Add the CookieConsent component to your root layout:

<PhoenixKit.Modules.Legal.CookieConsent.cookie_consent
frameworks={["gdpr"]}
phoenix_kit_current_scope={@phoenix_kit_current_scope}
/>

Pass phoenix_kit_current_scope={@phoenix_kit_current_scope} so the component can decide server-side whether to render for authenticated users. The assign is already available in root layouts wired via PhoenixKitWeb.Integration. Omitting it is safe — the widget renders for everyone (same as an anonymous visitor), but the "Hide for authenticated users" setting will have no effect.

PhoenixKit auto-discovers the module at startup — no additional configuration needed.

Quick Start

  1. Add the dependency to mix.exs
  2. Run mix deps.get
  3. Enable the module in admin settings (legal_enabled: true)
  4. Select compliance frameworks (e.g., GDPR, CCPA)
  5. Fill in company and DPO contact information
  6. Generate legal pages — they appear under /admin/settings/legal
  7. Publish them — they go live at /legal/:slug with no routing work

No router changes are needed to serve public legal pages. The router scope in Installation is for the admin settings screen only.

Legal doesn't render public pages itself. It generates them into a phoenix_kit_publishing group slugged "legal", and Publishing's /:language/:group/*path dispatch serves them like any other group:

URLWhat renders
/legalIndex of every published legal page
/legal/privacy-policyThat page
/en/legal/privacy-policySame page, explicit locale prefix

The division of labour is deliberate — Legal generates content, Publishing renders it. That means legal pages inherit everything Publishing already does: per-language versions, translation, in-place editing from the admin post editor, canonical / og:* / hreflang tags, and the language switcher. There's no second rendering path to keep in sync.

Pages are only reachable once published — a generated page sits in draft until you publish it, and drafts 404 for anonymous visitors. Check status under /admin/settings/legal, or call Legal.all_required_pages_published?/0.

get_published_legal_links/0 returns published pages as %{title:, url:} maps, which is what the cookie consent widget uses to build its links:

PhoenixKit.Modules.Legal.get_published_legal_links()
#=> [%{title: "Privacy Policy", url: "/legal/privacy-policy"},
# %{title: "Cookie Policy", url: "/legal/cookie-policy"}]

URLs are emitted without a locale prefix so the host app's locale plug resolves the visitor's current language on click.

Declaring a host route at /legal is not supported — Publishing's dispatch runs in the router's call/2 override, so it claims the path before your route is matched. To customize, either edit the generated posts in the Publishing editor, or override the EEx templates (see Template Customization).

Upgrading from 0.1.6? That release reserved /legal for a host-app LiveView this module never shipped, which 404'd public legal pages. 0.1.7 reverts it — see Upgrading.

Upgrading

mix deps.update phoenix_kit_legal phoenix_kit_publishing
mix phoenix_kit.update # apply any pending schema migrations
# restart the app

Both modules require phoenix_kit ~> 1.7.189, so an older pin is carried forward by the same command.

Then verify:

curl -I https://yoursite/legal # expect 200
curl -I https://yoursite/legal/privacy-policy # expect 200

Still 404 after upgrading?

Two causes, in order of likelihood:

  1. Pages are still drafts. Upgrading publishes nothing. A generated page sits in draft, and Publishing 404s unpublished posts for anonymous visitors. Open /admin/settings/legal and publish them, or check Legal.all_required_pages_published?/0.

    On 0.1.8 or earlier, publishing itself was broken.publish_page/2 — and the admin "Publish" button that calls it — reported success while leaving the page a draft (#11). If a page reads as published but still 404s, upgrade to 0.1.9+ and publish it again.

  2. A leftover /legal route from the 0.1.6 workaround. Delete it. Publishing's dispatch rewrites the path in the router's call/2 before route matching, so such a route is unreachable no matter where it sits in router.ex.

What you don't need to do

If the consent widget renders unstyled, your Tailwind build didn't pick up the module's CSS sources — run mix phoenix_kit.assets.rebuild.

Compliance Frameworks

FrameworkRegionConsent ModelRequired Pages
GDPREU/EEAOpt-inPrivacy Policy, Cookie Policy
UK GDPRUKOpt-inPrivacy Policy, Cookie Policy
CCPA/CPRACaliforniaOpt-outPrivacy Policy, Do Not Sell
US States15+ US statesOpt-outPrivacy Policy
LGPDBrazilOpt-inPrivacy Policy
PIPEDACanadaOpt-inPrivacy Policy
GenericGlobalNoticePrivacy Policy

Page Types

PageTemplateDescription
Privacy Policyprivacy_policy.eexData collection, processing, and rights
Cookie Policycookie_policy.eexCookie usage and management
Terms of Serviceterms_of_service.eexService terms and conditions
Do Not Selldo_not_sell.eexCCPA opt-out for data sales
Data Retentiondata_retention_policy.eexData retention periods and policies
CCPA Noticeccpa_notice.eexCalifornia-specific privacy notice
Acceptable Useacceptable_use.eexAcceptable use policy

The consent widget provides a glass-morphic UI with:

When enabled, the widget fires consent events for Google Tag Manager:

// Default (denied)
gtag('consent', 'default', { analytics_storage: 'denied', ad_storage: 'denied' });
// After user grants analytics
gtag('consent', 'update', { analytics_storage: 'granted' });

Template Customization

Override bundled templates by placing files in your parent app's priv/legal_templates/:

priv/legal_templates/
privacy_policy.eex # Base template override
privacy_policy.de.eex # German-specific override
cookie_policy.eex # Base template override

Template resolution order:

  1. Parent app language-specific: priv/legal_templates/{name}.{lang}.eex
  2. Bundled language-specific template
  3. Parent app base: priv/legal_templates/{name}.eex
  4. Bundled base template

Template Variables

All templates receive:

VariableDescription
@company_nameCompany legal name
@company_addressCompany registered address
@company_countryCompany country
@company_websiteCompany website URL
@registration_numberCompany registration number
@vat_numberVAT/tax ID number
@dpo_nameData Protection Officer name
@dpo_emailDPO email address
@dpo_phoneDPO phone number
@dpo_addressDPO postal address
@frameworksList of selected framework IDs
@effective_dateCurrent date (ISO format)
@languageLanguage code

Full audit trail for GDPR compliance:

alias PhoenixKit.Modules.Legal.ConsentLog
# Log consent for a user
ConsentLog.log_consents(
%{"analytics" => true, "marketing" => false},
user_uuid: user.uuid,
consent_version: "2026-03-27",
ip_address: "192.168.1.1",
user_agent: "Mozilla/5.0..."
)
# Check current consent status
ConsentLog.get_consent_status(user_uuid: user.uuid)
# => %{"analytics" => true, "marketing" => false, "necessary" => true}

Architecture

lib/phoenix_kit_legal/
phoenix_kit_legal.ex # Entry point, version info
legal.ex # Main module (PhoenixKit.Module behaviour)
legal_framework.ex # LegalFramework struct
page_type.ex # PageType struct
schemas/
consent_log.ex # Consent audit trail schema
services/
template_generator.ex # EEx template rendering
web/
consent_config_controller.ex # JSON API for widget config
cookie_consent.ex # Phoenix component (consent widget)
settings.ex # Admin settings LiveView
priv/
legal_templates/ # Bundled EEx templates (7 pages)
static/assets/
phoenix_kit_consent.js # Client-side consent manager

Database Table

phoenix_kit_consent_logs — Consent audit trail (UUIDv7 PK)

ColumnTypePurpose
uuidUUIDv7Primary key
user_uuidUUIDv7Logged-in user (optional)
session_idstringAnonymous session (optional)
consent_typestring"necessary", "analytics", "marketing", "preferences"
consent_givenbooleanWhether consent was granted
consent_versionstringPolicy version at time of consent
ip_addressstringIP when consent recorded
user_agent_hashstringSHA256 hash of user agent
metadataJSONBAdditional metadata

Requires either user_uuid or session_id (at least one must be present).

How It Works

  1. Parent app adds this as a dependency in mix.exs
  2. PhoenixKit scans .beam files at startup and auto-discovers the module (zero config)
  3. settings_tabs/0 registers the admin settings page
  4. Legal pages are generated from EEx templates and stored via the Publishing module
  5. Cookie consent widget is injected client-side via JavaScript
  6. Consent decisions are logged to phoenix_kit_consent_logs for audit compliance

Settings

KeyDefaultDescription
legal_enabledfalseEnable/disable module
legal_frameworks[]Selected compliance frameworks
legal_company_info{}Company details (name, address, etc.)
legal_dpo_contact{}DPO contact details
legal_consent_widget_enabledfalseEnable cookie consent widget
legal_consent_mode"strict"Consent mode: "strict" (opt-in) or "notice"
legal_cookie_banner_position"bottom-right"Widget icon position
legal_policy_version"1.0"Manual policy version string
legal_google_consent_modefalseEnable Google Consent Mode v2
legal_hide_for_authenticatedtrueHide widget for logged-in users

API Endpoint

GET /phoenix_kit/api/consent-config — Returns widget configuration as JSON.

Used by the client-side consent manager to initialize the widget. Cached publicly for 60 seconds. Auth-gating is handled server-side by the component, not this endpoint.

Development

mix deps.get # Install dependencies
mix test # Run tests
mix format # Format code
mix credo --strict # Static analysis (strict mode)
mix dialyzer # Type checking
mix docs # Generate documentation
mix precommit # Compile + format + credo + dialyzer
mix quality # Format + credo + dialyzer

Dependencies

PackagePurpose
phoenix_kitModule behaviour, Settings API, core infrastructure
phoenix_kit_publishingLegal page storage as posts
phoenix_live_viewAdmin settings LiveView
ecto_sqlConsent log schema
gettextTemplate internationalization

License

MIT — see LICENSE.md for details.