PhoenixKitLegal
Legal compliance module for PhoenixKit. GDPR, CCPA, LGPD, and PIPEDA compliant legal page generation, cookie consent widget, and consent audit logging.
Features
- 7 compliance frameworks — GDPR (EU/EEA), UK GDPR, CCPA/CPRA (California), US States (15+), LGPD (Brazil), PIPEDA (Canada), Generic
- Legal page generation — Privacy Policy, Cookie Policy, Terms of Service, Do Not Sell, Data Retention, CCPA Notice, Acceptable Use
- EEx template system — customizable templates with language support and template override from parent app
- Cookie consent widget — glass-morphic UI with floating icon, preferences modal, and consent banner
- Google Consent Mode v2 — built-in integration for analytics/marketing consent signals
- Consent audit logging — full audit trail with user/session tracking, IP, and hashed user agent
- Publishing integration — legal pages stored as posts via PhoenixKit Publishing for versioning and multi-language support
- Admin settings UI — framework selection, company/DPO info, page generation, widget configuration
- Auto-discovery — implements
PhoenixKit.Modulebehaviour; PhoenixKit finds it at startup with zero config
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:
| Step | What it does |
|---|---|
lib/**/endpoint.ex | Adds Plug.Static at /phoenix_kit_legal to serve the consent JS |
assets/css/app.css | Adds @source "../../deps/phoenix_kit_legal" for Tailwind class scanning |
assets/js/app.js | Adds 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
- Add the dependency to
mix.exs - Run
mix deps.get - Enable the module in admin settings (
legal_enabled: true) - Select compliance frameworks (e.g., GDPR, CCPA)
- Fill in company and DPO contact information
- Generate legal pages — they appear under
/admin/settings/legal - Publish them — they go live at
/legal/:slugwith no routing work
Public Legal Pages
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:
| URL | What renders |
|---|---|
/legal | Index of every published legal page |
/legal/privacy-policy | That page |
/en/legal/privacy-policy | Same 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.
Linking to legal pages
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.
If you want a custom /legal page
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
/legalfor 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:
- Pages are still drafts. Upgrading publishes nothing. A generated page sits
in
draft, and Publishing 404s unpublished posts for anonymous visitors. Open/admin/settings/legaland publish them, or checkLegal.all_required_pages_published?/0. - A leftover
/legalroute from the 0.1.6 workaround. Delete it. Publishing's dispatch rewrites the path in the router'scall/2before route matching, so such a route is unreachable no matter where it sits inrouter.ex.
What you don't need to do
- No new migration is introduced by 0.1.7. The
phoenix_kit_consent_logsschema is unchanged.mix phoenix_kit.updateis still worth running — it's idempotent and picks up core migrations if you're several versions behind. - No cache to clear. Publishing resolves group slugs with a live DB lookup per request and recomputes reserved prefixes per call; a normal restart is enough.
- No config or settings changes, and no router changes.
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
| Framework | Region | Consent Model | Required Pages |
|---|---|---|---|
| GDPR | EU/EEA | Opt-in | Privacy Policy, Cookie Policy |
| UK GDPR | UK | Opt-in | Privacy Policy, Cookie Policy |
| CCPA/CPRA | California | Opt-out | Privacy Policy, Do Not Sell |
| US States | 15+ US states | Opt-out | Privacy Policy |
| LGPD | Brazil | Opt-in | Privacy Policy |
| PIPEDA | Canada | Opt-in | Privacy Policy |
| Generic | Global | Notice | Privacy Policy |
Page Types
| Page | Template | Description |
|---|---|---|
| Privacy Policy | privacy_policy.eex | Data collection, processing, and rights |
| Cookie Policy | cookie_policy.eex | Cookie usage and management |
| Terms of Service | terms_of_service.eex | Service terms and conditions |
| Do Not Sell | do_not_sell.eex | CCPA opt-out for data sales |
| Data Retention | data_retention_policy.eex | Data retention periods and policies |
| CCPA Notice | ccpa_notice.eex | California-specific privacy notice |
| Acceptable Use | acceptable_use.eex | Acceptable use policy |
Cookie Consent Widget
The consent widget provides a glass-morphic UI with:
- Floating icon (configurable position: bottom-left, bottom-right, top-left, top-right)
- Consent banner for first-time visitors
- Preferences modal with 4 consent categories (necessary, analytics, marketing, preferences)
- Dark mode support via daisyUI CSS variables
- ARIA-compliant accessibility
- localStorage persistence with cross-tab sync
- Automatic DOM injection (no layout changes required)
- Authentication-aware display (optional hide for logged-in users)
Google Consent Mode v2
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:
- Parent app language-specific:
priv/legal_templates/{name}.{lang}.eex - Bundled language-specific template
- Parent app base:
priv/legal_templates/{name}.eex - Bundled base template
Template Variables
All templates receive:
| Variable | Description |
|---|---|
@company_name | Company legal name |
@company_address | Company registered address |
@company_country | Company country |
@company_website | Company website URL |
@registration_number | Company registration number |
@vat_number | VAT/tax ID number |
@dpo_name | Data Protection Officer name |
@dpo_email | DPO email address |
@dpo_phone | DPO phone number |
@dpo_address | DPO postal address |
@frameworks | List of selected framework IDs |
@effective_date | Current date (ISO format) |
@language | Language code |
Consent Logging
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)
| Column | Type | Purpose |
|---|---|---|
uuid | UUIDv7 | Primary key |
user_uuid | UUIDv7 | Logged-in user (optional) |
session_id | string | Anonymous session (optional) |
consent_type | string | "necessary", "analytics", "marketing", "preferences" |
consent_given | boolean | Whether consent was granted |
consent_version | string | Policy version at time of consent |
ip_address | string | IP when consent recorded |
user_agent_hash | string | SHA256 hash of user agent |
metadata | JSONB | Additional metadata |
Requires either user_uuid or session_id (at least one must be present).
How It Works
- Parent app adds this as a dependency in
mix.exs - PhoenixKit scans
.beamfiles at startup and auto-discovers the module (zero config) settings_tabs/0registers the admin settings page- Legal pages are generated from EEx templates and stored via the Publishing module
- Cookie consent widget is injected client-side via JavaScript
- Consent decisions are logged to
phoenix_kit_consent_logsfor audit compliance
Settings
| Key | Default | Description |
|---|---|---|
legal_enabled | false | Enable/disable module |
legal_frameworks | [] | Selected compliance frameworks |
legal_company_info | {} | Company details (name, address, etc.) |
legal_dpo_contact | {} | DPO contact details |
legal_consent_widget_enabled | false | Enable 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_mode | false | Enable Google Consent Mode v2 |
legal_hide_for_authenticated | true | Hide 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
| Package | Purpose |
|---|---|
phoenix_kit | Module behaviour, Settings API, core infrastructure |
phoenix_kit_publishing | Legal page storage as posts |
phoenix_live_view | Admin settings LiveView |
ecto_sql | Consent log schema |
gettext | Template internationalization |
License
MIT — see LICENSE.md for details.