# credo:disable-for-this-file Credo.Check.Refactor.LongQuoteBlocks
defmodule PhoenixKitWeb.Integration do
@compile {:no_warn_undefined,
[
PhoenixKitEcommerce,
PhoenixKitEcommerce.Web.Routes,
PhoenixKitEcommerce.Web.Plugs.ShopSession,
PhoenixKitEcommerce.Web.UserOrders,
PhoenixKitEcommerce.Web.UserOrderDetails,
PhoenixKitWeb.Live.Modules.Legal.Settings,
PhoenixKitWeb.Controllers.ConsentConfigController
]}
@moduledoc """
Integration helpers for adding PhoenixKit to Phoenix applications.
## Basic Usage
Add to your router:
defmodule MyAppWeb.Router do
use MyAppWeb, :router
import PhoenixKitWeb.Integration
# Add PhoenixKit routes
phoenix_kit_routes() # Default: /phoenix_kit prefix
end
## Automatic Integration
When you run `mix phoenix_kit.install`, the following is automatically added to your
`:browser` pipeline:
plug PhoenixKitWeb.Plugs.Integration
This plug handles all PhoenixKit features including maintenance mode, and ensures
they work across your entire application
## Layout Integration
Configure parent layouts in config.exs:
config :phoenix_kit,
repo: MyApp.Repo,
layout: {MyAppWeb.Layouts, :app},
root_layout: {MyAppWeb.Layouts, :root}
## Authentication Callbacks
Use in your app's live_sessions:
- `:phoenix_kit_mount_current_scope` - Mounts user and scope (recommended)
- `:phoenix_kit_ensure_authenticated_scope` - Requires authentication
- `:phoenix_kit_redirect_if_authenticated_scope` - Redirects if logged in
## Routes Created
Authentication routes:
- /users/register, /users/log-in, /users/magic-link
- /users/reset-password, /users/confirm
- /users/log-out (GET/DELETE)
User dashboard routes (if enabled, default: true):
- /dashboard, /dashboard/settings
- /dashboard/settings/confirm-email/:token
Admin routes (Owner/Admin only):
- /admin, /admin/users, /admin/users/roles
- /admin/users/live_sessions, /admin/users/sessions
- /admin/settings, /admin/modules
## Configuration
You can disable the user dashboard by setting the environment variable in your config:
# config/dev.exs or config/runtime.exs
config :phoenix_kit, user_dashboard_enabled: false
This will disable all dashboard routes (/dashboard/*). Users trying to access
the dashboard will get a 404 error.
## DaisyUI Setup
1. Install: `npm install daisyui@latest`
2. Add to tailwind.config.js:
- Content: `"../../deps/phoenix_kit"`
- Plugin: `require('daisyui')`
## Layout Templates
Use `{@inner_content}` not `render_slot(@inner_block)`:
<%!-- Correct --%>
{@inner_content}
## Scope Usage in Templates
<%= if PhoenixKit.Users.Auth.Scope.authenticated?(@phoenix_kit_current_scope) do %>
Welcome, {PhoenixKit.Users.Auth.Scope.user_email(@phoenix_kit_current_scope)}!
<% end %>
"""
alias PhoenixKit.Utils.Routes
alias PhoenixKitWeb
@doc """
Creates locale-aware routing scopes based on enabled languages.
This macro generates both a localized scope (e.g., `/en/`) and a non-localized
scope for backward compatibility. The locale pattern is dynamically generated
from the database-stored enabled language codes.
## Examples
locale_scope do
live "/admin", DashboardLive, :index
end
# Generates routes like:
# /phoenix_kit/en/admin (with locale)
# /phoenix_kit/admin (without locale, defaults to "en")
"""
defmacro locale_scope(opts \\ [], do: block) do
# Get URL prefix at compile time
raw_prefix =
try do
PhoenixKit.Config.get_url_prefix()
rescue
_ -> "/phoenix_kit"
end
url_prefix =
case raw_prefix do
"" -> "/"
prefix -> prefix
end
quote do
alias PhoenixKit.Modules.Languages
# Define locale validation pipeline
pipeline :phoenix_kit_locale_validation do
plug PhoenixKitWeb.Users.Auth, :phoenix_kit_validate_and_set_locale
end
# Localized scope with flexible locale pattern
# Accepts both base codes (en, es) and full dialect codes (en-US, es-MX)
# Full dialect codes are automatically redirected to base codes by the validation plug
# This ensures backward compatibility with old URLs while enforcing base code standard
scope "#{unquote(url_prefix)}/:locale",
PhoenixKitWeb,
Keyword.put(unquote(opts), :locale, ~r/^[a-z]{2,3}(?:-[A-Za-z]{2,4})?$/) do
pipe_through [:browser, :phoenix_kit_auto_setup, :phoenix_kit_locale_validation]
unquote(block)
end
# Non-localized scope for backward compatibility (defaults to "en")
scope unquote(url_prefix), PhoenixKitWeb, unquote(opts) do
pipe_through [:browser, :phoenix_kit_auto_setup, :phoenix_kit_locale_validation]
unquote(block)
end
end
end
# Helper function to generate pipeline definitions
defp generate_pipelines do
# Shop session pipeline — conditional on Shop package being installed
shop_session_pipeline =
if Code.ensure_loaded?(PhoenixKitEcommerce.Web.Plugs.ShopSession) do
quote do
pipeline :phoenix_kit_shop_session do
plug PhoenixKitEcommerce.Web.Plugs.ShopSession
end
end
else
quote do
end
end
quote do
alias PhoenixKit.Modules.Languages
# Define the auto-setup pipeline
pipeline :phoenix_kit_auto_setup do
plug PhoenixKitWeb.Plugs.RequestTimer
plug PhoenixKitWeb.Users.Auth, :fetch_phoenix_kit_current_user
plug PhoenixKitWeb.Integration, :phoenix_kit_auto_setup
end
pipeline :phoenix_kit_redirect_if_authenticated do
plug PhoenixKitWeb.Users.Auth, :phoenix_kit_redirect_if_user_is_authenticated
end
pipeline :phoenix_kit_require_authenticated do
plug PhoenixKitWeb.Users.Auth, :fetch_phoenix_kit_current_user
plug PhoenixKitWeb.Users.Auth, :phoenix_kit_require_authenticated_user
end
pipeline :phoenix_kit_admin_only do
plug PhoenixKitWeb.Users.Auth, :fetch_phoenix_kit_current_user
plug PhoenixKitWeb.Users.Auth, :fetch_phoenix_kit_current_scope
plug PhoenixKitWeb.Users.Auth, :phoenix_kit_require_admin
end
pipeline :phoenix_kit_optional_scope do
plug PhoenixKitWeb.Users.Auth, :fetch_phoenix_kit_current_scope
end
# Define API pipeline for JSON endpoints
pipeline :phoenix_kit_api do
plug :accepts, ["json"]
end
# Define locale validation pipeline
pipeline :phoenix_kit_locale_validation do
plug PhoenixKitWeb.Users.Auth, :phoenix_kit_validate_and_set_locale
end
# Shop session pipeline (only when phoenix_kit_ecommerce is installed)
unquote(shop_session_pipeline)
end
end
# Helper function to generate basic scope routes
defp generate_basic_scope(url_prefix) do
quote do
scope unquote(url_prefix), PhoenixKitWeb do
pipe_through [:browser, :phoenix_kit_auto_setup]
post "/users/log-in", Users.Session, :create
delete "/users/log-out", Users.Session, :delete
get "/users/log-out", Users.Session, :get_logout
post "/users/session/accounts", Users.Session, :add_account
put "/users/session/active", Users.Session, :set_active_account
delete "/users/session/accounts/:ref", Users.Session, :remove_account
get "/users/magic-link/:token", Users.MagicLinkVerify, :verify
# QR device-handoff login — completion endpoint (single-use login token)
get "/users/qr-login/finish/:token", Users.QrLoginComplete, :complete
# Dashboard context switching (multi-selector with key, must come before legacy route)
post "/context/:key/:id", ContextController, :set
# Dashboard context switching (legacy single selector)
post "/context/:id", ContextController, :set
# OAuth routes for external provider authentication
get "/users/auth/:provider", Users.OAuth, :request
get "/users/auth/:provider/callback", Users.OAuth, :callback
# Magic Link Registration routes
get "/users/register/verify/:token", Users.MagicLinkRegistrationVerify, :verify
# Note: Email webhook moved to generate_emails_routes/1 (separate scope)
# Storage API routes (file upload and serving)
post "/api/upload", UploadController, :create
get "/file/:file_uuid/:variant/:token", FileController, :show
get "/api/files/:file_uuid/info", FileController, :info
# Token in the path (not query string) so OpenSeadragon's tile-URL
# derivation preserves it — OSD strips `.dzi?query=...` to build
# `_files//_.`, but a path-token
# `/tiles//.dzi` becomes a clean base of
# `/tiles//` and tile URLs inherit the token.
get "/tiles/:token/:dzi_filename", FileController, :serve_manifest
get "/tiles/:token/:files_segment/:level/:tile_filename", FileController, :serve_tile
# Cookie consent widget config (public API for JS auto-injection)
if Code.ensure_loaded?(PhoenixKit.Modules.Legal) do
get "/api/consent-config", Controllers.ConsentConfigController, :config
end
end
# Maintenance mode page — public LiveView, no auth required.
# Uses :phoenix_kit_mount_current_scope to detect admin users (for preview banner).
# The maintenance page view is in skip_maintenance_check? so it won't redirect to itself.
scope unquote(url_prefix), PhoenixKitWeb do
pipe_through [:browser, :phoenix_kit_auto_setup]
live_session :phoenix_kit_maintenance,
on_mount: [{PhoenixKitWeb.Users.Auth, :phoenix_kit_mount_current_scope}] do
live "/maintenance", Live.Modules.Maintenance.Page, :index
end
end
# Note: Email export routes moved to generate_emails_routes/1 (separate scope)
# PhoenixKit static assets (no CSRF protection needed for static files)
scope unquote(url_prefix), PhoenixKitWeb do
pipe_through [:phoenix_kit_api]
get "/assets/:file", AssetsController, :serve
end
# Router-served fallback for phoenix_kit_catalogue's vendored PDF.js
# viewer. Served at the literal `/_pdfjs` path (no prefix/locale) so it
# matches the same URL the catalogue's iframe + endpoint `Plug.Static`
# mount use — the endpoint mount (when present) wins because endpoint
# plugs precede the router; this only catches the fall-through on a host
# whose endpoint never got the mount. Compiled in only when the
# catalogue module is loaded.
if Code.ensure_loaded?(PhoenixKitCatalogue.Catalogue.PdfLibrary) do
# No pipeline: these are public static assets (HTML / JS modules /
# CSS / fonts) served by the controller, which sets its own
# content-type. The `:phoenix_kit_api` pipeline restricts to JSON
# (`plug :accepts, ["json"]`) and would mis-negotiate text/html and
# text/javascript for strict Accept headers.
scope "/_pdfjs", PhoenixKitWeb do
get "/*path", PdfViewerController, :serve
end
end
# Sitemap routes - public XML/XSL endpoints, no session/CSRF/auto_setup needed
scope unquote(url_prefix) do
get "/sitemap.xml", PhoenixKit.Modules.Sitemap.Web.Controller, :xml
get "/sitemap.html", PhoenixKit.Modules.Sitemap.Web.Controller, :html
get "/sitemaps/:filename", PhoenixKit.Modules.Sitemap.Web.Controller, :module_sitemap
get "/sitemap.xsl", PhoenixKit.Modules.Sitemap.Web.Controller, :xsl_stylesheet
get "/assets/sitemap/:style", PhoenixKit.Modules.Sitemap.Web.Controller, :xsl_stylesheet
get "/assets/sitemap-index/:style",
PhoenixKit.Modules.Sitemap.Web.Controller,
:xsl_index_stylesheet
end
# Shop public routes are generated via generate_shop_public_routes/1 helper
# This supports locale-prefixed URLs (/:locale/shop/...) with language switching
# Shop user dashboard routes are now in phoenix_kit_authenticated_routes/1.
end
end
# ============================================================================
# Shared Route Definitions
# ============================================================================
# These macros generate route definitions that are shared between localized
# and non-localized scopes. This eliminates code duplication and reduces
# compile time by ~50% for router files.
# ============================================================================
# Generates the public route table (auth + confirmation + shop) for one
# URL shape (`suffix` is `:_locale` or `:""`). Emits the `live`/`scope`
# entries only — the wrapping `live_session :phoenix_kit_public` is
# supplied by `generate_public_live_routes/2`, so both URL shapes share
# a single session and a front-end locale switch stays on the
# WebSocket instead of crossing a live_session boundary.
#
# Auth LiveViews handle the redirect-if-authenticated check in their own
# mount/3, so the shared session uses the permissive
# :phoenix_kit_mount_current_scope hook.
defmacro phoenix_kit_public_routes(suffix) do
# Get shop live route declarations at compile time (no scope/pipeline wrappers)
shop_live_routes =
if Code.ensure_loaded?(PhoenixKitEcommerce.Web.Routes) do
if suffix == :_locale do
PhoenixKitEcommerce.Web.Routes.public_live_locale_routes()
else
PhoenixKitEcommerce.Web.Routes.public_live_routes()
end
else
quote do
end
end
quote do
# Auth pages — redirect-if-authenticated handled in each LiveView's mount/3
live "/users/register", Users.Registration, :new, as: :user_registration
live "/users/register/magic-link", Users.MagicLinkRegistrationRequest, :new,
as: :user_magic_link_registration_request
live "/users/register/complete/:token", Users.MagicLinkRegistration, :complete,
as: :user_magic_link_registration
live "/users/log-in", Users.Login, :new, as: :user_login
live "/users/magic-link", Users.MagicLink, :new, as: :user_magic_link
# QR device-handoff login — desktop page showing the code to scan
live "/users/qr-login", Users.QrLogin, :new, as: :user_qr_login
live "/users/reset-password", Users.ForgotPassword, :new, as: :user_reset_password
live "/users/reset-password/:token", Users.ResetPassword, :edit,
as: :user_reset_password_edit
# Confirmation pages — no redirect check needed
live "/users/confirm/:token", Users.Confirmation, :edit, as: :user_confirmation
live "/users/confirm", Users.ConfirmationInstructions, :new,
as: :user_confirmation_instructions
# Shop public pages — same session for seamless auth → shop navigation
# Full module names required (no PhoenixKitWeb alias in shop namespace)
scope "/", alias: false do
unquote(shop_live_routes)
end
end
end
# Generates the admin route table for one URL shape (`suffix` is
# `:_locale` or `:""`). Emits the `live`/`scope` entries only — the
# wrapping `live_session :phoenix_kit_admin` is supplied by
# `generate_admin_routes/2`, so both URL shapes share a single
# session and an in-admin locale switch stays on the WebSocket
# instead of crossing a live_session boundary (full-page reload).
defmacro phoenix_kit_admin_routes(suffix) do
# Auto-generate routes for custom admin tabs that specify live_view
# Skip when compiling PhoenixKit's own dev/test router — parent modules don't exist
custom_admin_routes = compile_custom_admin_routes(__CALLER__.module)
# Plugin module routes get their own live_session with admin layout
# so plugin LiveViews don't need to wrap with LayoutWrapper themselves
plugin_admin_routes = compile_plugin_admin_routes(__CALLER__.module)
# Shop admin routes via safe_route_call (only when phoenix_kit_ecommerce is installed)
shop_admin =
if suffix == :_locale do
safe_route_call(PhoenixKitEcommerce.Web.Routes, :admin_locale_routes, [])
else
safe_route_call(PhoenixKitEcommerce.Web.Routes, :admin_routes, [])
end
# External route modules with complex routes (beyond simple admin tabs)
external_admin_routes = compile_external_admin_routes(suffix)
quote do
# Core admin routes (under PhoenixKitWeb alias from parent scope)
live "/admin", Live.Dashboard, :index
live "/admin/users", Live.Users.Users, :index
live "/admin/users/new", Users.UserForm, :new, as: :user_form
live "/admin/users/edit/:id", Users.UserForm, :edit, as: :user_form_edit
live "/admin/users/view/:id", Live.Users.UserDetails, :show
live "/admin/users/roles", Live.Users.Roles, :index
live "/admin/users/permissions", Live.Users.PermissionsMatrix, :index
live "/admin/users/live_sessions", Live.Users.LiveSessions, :index
live "/admin/users/sessions", Live.Users.Sessions, :index
live "/admin/activity", Live.Activity.Index, :index
live "/admin/activity/:uuid", Live.Activity.Show, :show
live "/admin/notifications", Live.Modules.Notifications.Index, :index
live "/admin/media", Live.Users.Media, :index
live "/admin/media/:file_uuid", Live.Users.MediaDetail, :show
live "/admin/media/selector", Live.Users.MediaSelector, :index
live "/admin/settings", Live.Settings, :index
live "/admin/settings/users", Live.Settings.Users, :index
live "/admin/settings/authorization", Live.Settings.Authorization, :index
live "/admin/settings/organization", Live.Settings.Organization, :index
live "/admin/settings/integrations", Live.Settings.Integrations, :index
live "/admin/settings/integrations/new", Live.Settings.IntegrationForm, :new
live "/admin/settings/integrations/:uuid", Live.Settings.IntegrationForm, :edit
live "/admin/modules", Live.Modules, :index
live "/admin/settings/languages", Live.Modules.Languages, :index
live "/admin/settings/languages/frontend", Live.Modules.Languages, :frontend
live "/admin/settings/maintenance", Live.Modules.Maintenance.Settings, :index
live "/admin/settings/seo", Live.Settings.SEO, :index
live "/admin/settings/media", Live.Modules.Storage.Settings, :index
live "/admin/settings/media/buckets/new", Live.Modules.Storage.BucketForm, :new
live "/admin/settings/media/buckets/:id/edit", Live.Modules.Storage.BucketForm, :edit
live "/admin/settings/media/dimensions", Live.Modules.Storage.Dimensions, :index
live "/admin/settings/media/health", Live.Modules.Storage.Health, :index
live "/admin/settings/media/dimensions/new/image",
Live.Modules.Storage.DimensionForm,
:new_image
live "/admin/settings/media/dimensions/new/video",
Live.Modules.Storage.DimensionForm,
:new_video
live "/admin/settings/media/dimensions/:id/edit",
Live.Modules.Storage.DimensionForm,
:edit
# Jobs
live "/admin/jobs", Live.Modules.Jobs.Index, :index
# Module admin routes (use alias: false to prevent PhoenixKitWeb prefix
# since these modules use their own namespaces like PhoenixKit.Modules.*)
scope "/", alias: false do
# Sitemap settings
live "/admin/settings/sitemap",
PhoenixKit.Modules.Sitemap.Web.Settings,
:index,
as: :sitemap_settings
# Shop admin routes (only when phoenix_kit_ecommerce is installed)
unquote(shop_admin)
# Custom admin routes from :admin_dashboard_tabs config
# Tabs with live_view: {Module, :action} get auto-generated routes
# in the shared admin live_session for seamless navigation
unquote_splicing(custom_admin_routes)
# External route modules (complex multi-page routes)
unquote_splicing(external_admin_routes)
# Plugin module routes (in same live_session for seamless navigation).
# Admin layout is auto-applied via on_mount for external plugin views.
unquote_splicing(plugin_admin_routes)
end
end
end
# Generates the authenticated user route table (dashboard + shop user +
# tickets user) for one URL shape (`suffix` is `:_locale` or `:""`).
# Emits the `live`/`scope` entries only — the wrapping
# `live_session :phoenix_kit_authenticated` is supplied by
# `generate_authenticated_live_routes/2`, so both URL shapes share a
# single session and an in-dashboard locale switch stays on the
# WebSocket. Module routes use alias: false since they live outside
# the PhoenixKitWeb namespace.
defmacro phoenix_kit_authenticated_routes(suffix) do
module_routes =
if suffix == :_locale do
authenticated_live_locale_routes()
else
authenticated_live_routes()
end
quote do
# Core dashboard routes (conditional on config)
if unquote(PhoenixKit.Config.user_dashboard_enabled?()) do
live "/dashboard", Live.Dashboard.Index, :index
live "/dashboard/settings", Live.Dashboard.Settings, :edit
live "/dashboard/settings/confirm-email/:token",
Live.Dashboard.Settings,
:confirm_email
end
# QR device-handoff login — the phone-side approval screen. Must be
# authenticated: the phone approves on behalf of its signed-in user.
live "/users/qr-login/scan/:token", Users.QrLoginConfirm, :confirm,
as: :user_qr_login_confirm
# Module user pages (full module names — no PhoenixKitWeb alias)
scope "/", alias: false do
unquote(module_routes)
end
end
end
defp authenticated_live_routes do
shop_user_routes =
if Code.ensure_loaded?(PhoenixKitEcommerce.Web.UserOrders) do
quote do
live "/dashboard/orders", PhoenixKitEcommerce.Web.UserOrders, :index,
as: :shop_user_orders
live "/dashboard/orders/:uuid", PhoenixKitEcommerce.Web.UserOrderDetails, :show,
as: :shop_user_order_details
end
else
quote do
end
end
billing_user_routes =
if Code.ensure_loaded?(PhoenixKitBilling.Web.UserBillingProfiles) do
quote do
live "/dashboard/billing-profiles",
PhoenixKitBilling.Web.UserBillingProfiles,
:index,
as: :billing_user_profiles
live "/dashboard/billing-profiles/new",
PhoenixKitBilling.Web.UserBillingProfileForm,
:new,
as: :billing_user_profile_new
live "/dashboard/billing-profiles/:uuid/edit",
PhoenixKitBilling.Web.UserBillingProfileForm,
:edit,
as: :billing_user_profile_edit
end
else
quote do
end
end
cs_user_routes =
if Code.ensure_loaded?(PhoenixKitCustomerSupport.Web.UserList) do
quote do
live "/dashboard/customer-support/tickets",
PhoenixKitCustomerSupport.Web.UserList,
:index,
as: :tickets_user_list
live "/dashboard/customer-support/tickets/new",
PhoenixKitCustomerSupport.Web.UserNew,
:new,
as: :tickets_user_new
live "/dashboard/customer-support/tickets/:id",
PhoenixKitCustomerSupport.Web.UserDetails,
:show,
as: :tickets_user_details
end
else
quote do
end
end
quote do
unquote(shop_user_routes)
unquote(billing_user_routes)
unquote(cs_user_routes)
end
end
defp authenticated_live_locale_routes do
shop_user_locale_routes =
if Code.ensure_loaded?(PhoenixKitEcommerce.Web.UserOrders) do
quote do
live "/dashboard/orders", PhoenixKitEcommerce.Web.UserOrders, :index,
as: :shop_user_orders_locale
live "/dashboard/orders/:uuid", PhoenixKitEcommerce.Web.UserOrderDetails, :show,
as: :shop_user_order_details_locale
end
else
quote do
end
end
billing_user_locale_routes =
if Code.ensure_loaded?(PhoenixKitBilling.Web.UserBillingProfiles) do
quote do
live "/dashboard/billing-profiles",
PhoenixKitBilling.Web.UserBillingProfiles,
:index,
as: :billing_user_profiles_locale
live "/dashboard/billing-profiles/new",
PhoenixKitBilling.Web.UserBillingProfileForm,
:new,
as: :billing_user_profile_new_locale
live "/dashboard/billing-profiles/:uuid/edit",
PhoenixKitBilling.Web.UserBillingProfileForm,
:edit,
as: :billing_user_profile_edit_locale
end
else
quote do
end
end
cs_user_locale_routes =
if Code.ensure_loaded?(PhoenixKitCustomerSupport.Web.UserList) do
quote do
live "/dashboard/customer-support/tickets",
PhoenixKitCustomerSupport.Web.UserList,
:index,
as: :tickets_user_list_locale
live "/dashboard/customer-support/tickets/new",
PhoenixKitCustomerSupport.Web.UserNew,
:new,
as: :tickets_user_new_locale
live "/dashboard/customer-support/tickets/:id",
PhoenixKitCustomerSupport.Web.UserDetails,
:show,
as: :tickets_user_details_locale
end
else
quote do
end
end
quote do
unquote(shop_user_locale_routes)
unquote(billing_user_locale_routes)
unquote(cs_user_locale_routes)
end
end
# Generates user dashboard routes (conditional on config).
# @deprecated Use phoenix_kit_authenticated_routes/1 instead.
defmacro phoenix_kit_dashboard_routes(suffix) do
session_name = :"phoenix_kit_user_dashboard#{suffix}"
quote do
if unquote(PhoenixKit.Config.user_dashboard_enabled?()) do
live_session unquote(session_name),
on_mount: [
{PhoenixKitWeb.Users.Auth, :phoenix_kit_ensure_authenticated_scope},
{PhoenixKitWeb.Dashboard.ContextProvider, :default}
] do
live "/dashboard", Live.Dashboard.Index, :index
live "/dashboard/settings", Live.Dashboard.Settings, :edit
live "/dashboard/settings/confirm-email/:token",
Live.Dashboard.Settings,
:confirm_email
end
end
end
end
# Reads :admin_dashboard_tabs config at compile time and generates
# `live` route declarations for tabs that specify a `live_view` field.
# Returns a list of quoted expressions for use with unquote_splicing.
#
# ## Tab config example
#
# config :phoenix_kit, :admin_dashboard_tabs, [
# %{id: :admin_analytics, label: "Analytics", path: "analytics",
# live_view: {MyAppWeb.AnalyticsLive, :index}, permission: "dashboard"}
# ]
#
@doc false
def compile_custom_admin_routes(caller_module) do
# PhoenixKit's own router is only for dev/test — parent app modules
# aren't available when it compiles, so skip custom route generation.
if caller_module == PhoenixKitWeb.Router do
[]
else
compile_custom_admin_routes_internal()
end
end
@doc false
def compile_plugin_admin_routes(caller_module) do
if caller_module == PhoenixKitWeb.Router do
[]
else
compile_module_admin_routes()
end
end
defp compile_custom_admin_routes_internal do
# Process :admin_dashboard_tabs config (new format, flat list with live_view)
tabs_routes =
case PhoenixKit.Config.get(:admin_dashboard_tabs) do
{:ok, tabs} when is_list(tabs) ->
tabs
|> Enum.filter(fn tab ->
is_map(tab) and Map.has_key?(tab, :live_view) and
match?({module, _action} when is_atom(module), tab.live_view)
end)
|> Enum.map(&tab_to_route/1)
_ ->
[]
end
# Process legacy :admin_dashboard_categories config (deprecated, hierarchical)
# Auto-infer LiveView modules from subsection URLs
legacy_routes = compile_legacy_admin_routes()
tabs_routes ++ legacy_routes
end
# Generates routes from legacy admin_dashboard_categories config.
# Reads categories at compile time and infers LiveView modules from URL patterns.
defp compile_legacy_admin_routes do
case PhoenixKit.Config.get(:admin_dashboard_categories) do
{:ok, categories} when is_list(categories) ->
categories
|> Enum.flat_map(&routes_from_legacy_category/1)
_ ->
[]
end
end
# Generates routes from a single legacy category.
defp routes_from_legacy_category(category) do
subsections = category[:subsections] || []
Enum.flat_map(subsections, fn subsection ->
subsection_url = subsection[:url] || ""
case infer_live_view_from_legacy_url_with_fallback(subsection_url) do
{:ok, live_view} ->
[tab_to_route_from_url(subsection_url, live_view, subsection[:id])]
:error ->
[]
end
end)
end
# Infers LiveView module with fallback to assuming it exists during parent app compilation.
defp infer_live_view_from_legacy_url_with_fallback("/admin/" <> path_segments) do
app_base = Routes.phoenix_kit_app_base()
segments =
path_segments
|> String.split("/")
|> Enum.reject(&(&1 == ""))
|> Enum.map(&Macro.camelize/1)
if segments == [] do
:error
else
module_name =
Module.concat(
[
app_base,
"PhoenixKit",
"Live",
"Admin"
] ++ segments
)
# Try to load the module first
case Code.ensure_loaded(module_name) do
{:module, _} ->
{:ok, module_name}
{:error, _} ->
# Module not yet compiled — assume it will be compiled shortly.
# Emit a warning so devs get feedback if the module truly doesn't exist.
IO.warn(
"[PhoenixKit] Auto-inferred LiveView #{inspect(module_name)} from legacy URL " <>
"\"/admin/#{path_segments}\" but module is not yet loaded. " <>
"If this route fails at runtime, ensure the module exists."
)
{:ok, module_name}
end
end
end
defp infer_live_view_from_legacy_url_with_fallback(_), do: :error
# Creates a route declaration from a URL path and LiveView module.
defp tab_to_route_from_url(path, live_view, tab_id) do
route_opts = if tab_id, do: [as: tab_id], else: []
quote do
live unquote(path), unquote(live_view), :index, unquote(route_opts)
end
end
# Auto-discover admin routes from external PhoenixKit modules.
# Uses beam file scanning (same pattern as protocol consolidation) — zero config needed.
# Modules declare their admin tabs with live_view field for route auto-generation.
# Collects both admin_tabs and settings_tabs for complete route coverage.
defp compile_module_admin_routes do
PhoenixKit.ModuleDiscovery.discover_external_modules()
|> Enum.flat_map(fn mod ->
case Code.ensure_compiled(mod) do
{:module, _} ->
admin = collect_module_tabs(mod, :admin_tabs)
settings = collect_module_tabs(mod, :settings_tabs)
admin ++ settings
_ ->
[]
end
end)
end
# Auto-discover public (non-admin) routes from external PhoenixKit modules.
# For each external module that implements route_module/0, calls generate(url_prefix)
# on the returned route module. This replaces hardcoded safe_route_call() lines when
# internal modules are extracted to separate packages.
defp compile_module_public_routes(url_prefix) do
PhoenixKit.ModuleDiscovery.discover_external_modules()
|> Enum.flat_map(fn mod ->
case Code.ensure_compiled(mod) do
{:module, _} ->
if function_exported?(mod, :route_module, 0) do
route_mod = mod.route_module()
compile_route_module_generate(route_mod, url_prefix)
else
[]
end
_ ->
[]
end
end)
end
defp compile_route_module_generate(nil, _url_prefix), do: []
defp compile_route_module_generate(route_mod, url_prefix) do
case Code.ensure_compiled(route_mod) do
{:module, _} ->
if function_exported?(route_mod, :generate, 1) do
normalize_routes(route_mod.generate(url_prefix))
else
[]
end
_ ->
[]
end
end
defp collect_module_tabs(mod, callback) do
alias PhoenixKit.Dashboard.Tab
context = tab_callback_context(callback)
if function_exported?(mod, callback, 0) do
apply(mod, callback, [])
|> Enum.map(&Tab.resolve_path(&1, context))
|> Enum.filter(&tab_has_live_view?/1)
|> Enum.uniq_by(fn %{path: path} -> path end)
|> Enum.map(&tab_struct_to_route/1)
else
[]
end
end
defp tab_callback_context(:admin_tabs), do: :admin
defp tab_callback_context(:settings_tabs), do: :settings
defp tab_callback_context(:user_dashboard_tabs), do: :user_dashboard
defp tab_has_live_view?(%{live_view: {mod, _action}}) when is_atom(mod) do
case Code.ensure_compiled(mod) do
{:module, _} ->
true
{:error, reason} ->
IO.warn(
"[PhoenixKit] Tab references LiveView #{inspect(mod)} which failed to compile: " <>
"#{inspect(reason)}. Route will be skipped."
)
false
end
end
defp tab_has_live_view?(_), do: false
defp tab_struct_to_route(%{live_view: {module, action}, path: path, id: id}) do
route_opts = if id, do: [as: id], else: []
quote do
live unquote(path), unquote(module), unquote(action), unquote(route_opts)
end
end
defp tab_to_route(tab) do
{module, action} = tab.live_view
path = tab[:path] || raise "Tab #{tab[:id]} has :live_view but no :path"
route_opts = if tab[:id], do: [as: tab[:id]], else: []
quote do
live unquote(path), unquote(module), unquote(action), unquote(route_opts)
end
end
# Safely call a route module function at compile time.
# Returns empty AST if the module isn't available (allows safe extraction).
@doc false
def safe_route_call(mod, fun, args) do
case Code.ensure_compiled(mod) do
{:module, _} when is_atom(fun) ->
if function_exported?(mod, fun, length(args)),
do: apply(mod, fun, args),
else: quote(do: nil)
{:error, reason} when reason in [:nofile, :unavailable] ->
quote(do: nil)
{:error, reason} ->
IO.warn(
"[PhoenixKit] Route module #{inspect(mod)} failed to compile: #{inspect(reason)}. " <>
"Its routes will be unavailable."
)
quote(do: nil)
end
end
# Compile admin routes from external route modules configured via:
# config :phoenix_kit, :route_modules, [MyApp.Routes.CustomRoutes]
# Each module should implement admin_routes/0 or admin_locale_routes/0
@doc false
def compile_external_admin_routes(suffix) do
fun = if suffix == :_locale, do: :admin_locale_routes, else: :admin_routes
all_route_modules()
|> Enum.flat_map(&collect_admin_routes(&1, fun))
end
# Collect route modules from config + auto-discovered external PhoenixKit modules.
#
# Uses Code.ensure_compiled/1 (not Code.ensure_loaded?/1) because this runs
# at compile time inside macros. ensure_compiled waits for the module to finish
# compiling if it's currently being compiled, avoiding false negatives during
# parallel compilation. ensure_loaded? only checks if the module is already
# loaded and would miss modules still being compiled.
defp all_route_modules do
config_modules = PhoenixKit.Config.get(:route_modules, [])
discovered_route_modules =
PhoenixKit.ModuleDiscovery.discover_external_modules()
|> Enum.flat_map(fn mod ->
case Code.ensure_compiled(mod) do
{:module, _} ->
if function_exported?(mod, :route_module, 0) do
case mod.route_module() do
nil -> []
route_mod -> [route_mod]
end
else
[]
end
_ ->
[]
end
end)
(config_modules ++ discovered_route_modules)
|> Enum.uniq()
end
defp collect_admin_routes(mod, fun) do
case Code.ensure_compiled(mod) do
{:module, _} -> resolve_admin_routes(mod, fun)
_ -> []
end
end
defp resolve_admin_routes(mod, fun) do
cond do
function_exported?(mod, fun, 0) -> normalize_routes(apply(mod, fun, []))
function_exported?(mod, :admin_routes, 0) -> normalize_routes(mod.admin_routes())
true -> []
end
end
# Compile public routes from external route modules.
# Each module should implement public_routes/1 (receives url_prefix).
@doc false
def compile_external_public_routes(url_prefix) do
all_route_modules()
|> Enum.flat_map(&collect_public_routes(&1, url_prefix))
end
defp collect_public_routes(mod, url_prefix) do
case Code.ensure_compiled(mod) do
{:module, _} ->
if function_exported?(mod, :public_routes, 1),
do: normalize_routes(mod.public_routes(url_prefix)),
else: []
_ ->
[]
end
end
defp normalize_routes(routes) when is_list(routes), do: routes
defp normalize_routes(route), do: [route]
# ============================================================================
# Route Scope Generators
# ============================================================================
# Helper function to generate the localized non-live auth endpoints
# (form POSTs, OAuth and token GETs). Every localized LiveView surface
# lives in a unified live_session generated elsewhere.
defp generate_localized_routes(url_prefix, pattern) do
# Only include shop session pipeline when the package is installed
public_pipelines =
if Code.ensure_loaded?(PhoenixKitEcommerce.Web.Plugs.ShopSession) do
[
:browser,
:phoenix_kit_auto_setup,
:phoenix_kit_shop_session,
:phoenix_kit_locale_validation
]
else
[:browser, :phoenix_kit_auto_setup, :phoenix_kit_locale_validation]
end
quote do
# Localized scope: non-live auth endpoints only (form POSTs, OAuth
# and token GETs). Every LiveView surface — public, admin and the
# authenticated dashboard — lives in its own unified live_session;
# see `generate_public_live_routes/2`, `generate_admin_routes/2`
# and `generate_authenticated_live_routes/2`.
scope "#{unquote(url_prefix)}/:locale", PhoenixKitWeb,
locale: ~r/^(#{unquote(pattern)})$/ do
pipe_through unquote(public_pipelines)
# POST routes for authentication (needed for locale-prefixed form submissions)
post "/users/log-in", Users.Session, :create
delete "/users/log-out", Users.Session, :delete
get "/users/log-out", Users.Session, :get_logout
post "/users/session/accounts", Users.Session, :add_account
put "/users/session/active", Users.Session, :set_active_account
delete "/users/session/accounts/:ref", Users.Session, :remove_account
get "/users/magic-link/:token", Users.MagicLinkVerify, :verify
# QR device-handoff login — completion endpoint (single-use login token)
get "/users/qr-login/finish/:token", Users.QrLoginComplete, :complete
# OAuth routes
get "/users/auth/:provider", Users.OAuth, :request
get "/users/auth/:provider/callback", Users.OAuth, :callback
# Magic Link Registration
get "/users/register/verify/:token", Users.MagicLinkRegistrationVerify, :verify
end
end
end
# Builds one unified `live_session` for a PhoenixKit LiveView surface
# (public, admin, or authenticated dashboard).
#
# Each surface spans two URL shapes — `//...` for the primary
# language and `//:locale/...` for every other locale. Both
# shapes share ONE `live_session`, so a locale switch `push_navigate`s
# between them and stays on the WebSocket; a split session would force
# a full-page reload (see the no-prefix plan doc).
#
# `route_macro` is the surface's route-table macro
# (`phoenix_kit_admin_routes` etc.), called once per URL shape with the
# suffix its route helpers expect.
defp build_live_surface(url_prefix, pattern, session_name, on_mount, pipelines, route_macro) do
localized_routes = {route_macro, [], [:_locale]}
root_routes = {route_macro, [], [:""]}
quote do
live_session unquote(session_name), on_mount: unquote(on_mount) do
scope "#{unquote(url_prefix)}/:locale", PhoenixKitWeb,
locale: ~r/^(#{unquote(pattern)})$/ do
pipe_through unquote(pipelines)
unquote(localized_routes)
end
scope unquote(url_prefix), PhoenixKitWeb do
pipe_through unquote(pipelines)
unquote(root_routes)
end
end
end
end
# Pipeline for the public and admin surfaces — neither has a plug-level
# auth gate (admin gates via the live_session `on_mount`). The
# shop-session plug is only present when phoenix_kit_ecommerce is.
defp public_admin_pipelines do
if Code.ensure_loaded?(PhoenixKitEcommerce.Web.Plugs.ShopSession) do
[
:browser,
:phoenix_kit_auto_setup,
:phoenix_kit_shop_session,
:phoenix_kit_locale_validation
]
else
[:browser, :phoenix_kit_auto_setup, :phoenix_kit_locale_validation]
end
end
# Public auth/confirmation LiveViews. The permissive
# :phoenix_kit_mount_current_scope hook is fine — each LiveView runs
# its own redirect-if-authenticated check in mount/3. Non-live auth
# endpoints (form POSTs, OAuth/token GETs) stay outside this
# live_session — in `generate_basic_scope/1` and the localized scope
# of `generate_localized_routes/2`.
defp generate_public_live_routes(url_prefix, pattern) do
build_live_surface(
url_prefix,
pattern,
:phoenix_kit_public,
[{PhoenixKitWeb.Users.Auth, :phoenix_kit_mount_current_scope}],
public_admin_pipelines(),
:phoenix_kit_public_routes
)
end
# Admin LiveViews. Gated by the `:phoenix_kit_ensure_admin` on_mount —
# no plug-level auth gate, so the pipeline matches the public surface.
defp generate_admin_routes(url_prefix, pattern) do
build_live_surface(
url_prefix,
pattern,
:phoenix_kit_admin,
[{PhoenixKitWeb.Users.Auth, :phoenix_kit_ensure_admin}],
public_admin_pipelines(),
:phoenix_kit_admin_routes
)
end
# Authenticated dashboard LiveViews. Unlike public/admin these carry a
# plug-level auth gate (`:phoenix_kit_require_authenticated`) plus the
# ensure-authenticated-scope and context-provider on_mount hooks.
defp generate_authenticated_live_routes(url_prefix, pattern) do
build_live_surface(
url_prefix,
pattern,
:phoenix_kit_authenticated,
[
{PhoenixKitWeb.Users.Auth, :phoenix_kit_ensure_authenticated_scope},
{PhoenixKitWeb.Dashboard.ContextProvider, :default}
],
[
:browser,
:phoenix_kit_auto_setup,
:phoenix_kit_require_authenticated,
:phoenix_kit_locale_validation
],
:phoenix_kit_authenticated_routes
)
end
defmacro phoenix_kit_routes do
# OAuth configuration is handled by PhoenixKit.Workers.OAuthConfigLoader
# which runs synchronously during supervisor startup
# No need for async spawn() here anymore
# Get URL prefix at compile time and handle empty string case for router compatibility
raw_prefix =
try do
PhoenixKit.Config.get_url_prefix()
rescue
# Fallback if config not available at compile time
_ -> "/phoenix_kit"
end
url_prefix =
case raw_prefix do
"" -> "/"
prefix -> prefix
end
# Use a generic locale pattern that accepts any valid language code format
# This allows switching to any of the 80+ predefined languages
# Actual validation of whether the locale is supported happens in the validation plug
pattern = "[a-z]{2,3}(?:-[A-Za-z]{2,4})?"
# External route modules with public/non-admin routes
external_public_routes = compile_external_public_routes(url_prefix)
# Auto-discovered public routes from external PhoenixKit modules
module_public_routes = compile_module_public_routes(url_prefix)
# Publishing routing-strategy integration. When the publishing module
# (with its `RouterDispatch` helper) is in the dep tree, emit:
#
# * the internal-prefix scope (`/__phoenix_kit_publishing_dispatch/...`)
# under which publishing's catch-all routes are registered
# * a `def call/2` override that path-rewrites publishing-bound URLs
# into that prefix so Phoenix's matcher dispatches via the standard
# pipeline (sessions, CSRF, locale, scope) and host routes still
# win for URLs that don't resolve to a known publishing group.
#
# See `PhoenixKitPublishing.RouterDispatch` for the full mechanism
# (`maybe_rewrite/1`, `restore_path/2`, the `defoverridable call/2`
# extension point on Phoenix.Router).
publishing_routing = compile_publishing_routing(url_prefix)
# Snapshot discovered modules so the host router auto-recompiles when deps change
current_hash = PhoenixKit.ModuleDiscovery.module_hash()
mix_lock_path = Path.expand("mix.lock")
quote do
# Recompile router when deps change (mix.lock is updated by mix deps.get)
@external_resource unquote(mix_lock_path)
# Precise check: only actually recompile if the set of PhoenixKit modules changed
@doc false
def __mix_recompile__? do
unquote(current_hash) != PhoenixKit.ModuleDiscovery.module_hash()
end
# Generate pipeline definitions
unquote(generate_pipelines())
# Generate basic routes scope
unquote(generate_basic_scope(url_prefix))
# Auto-discovered public routes from external modules MUST come before publishing/localized
# routes to prevent /:language/:group catch-alls from intercepting them (e.g., unsubscribe)
unquote_splicing(module_public_routes)
# Generate localized non-live auth endpoints (form POSTs, token GETs)
unquote(generate_localized_routes(url_prefix, pattern))
# Generate the LiveView surfaces — each a single live_session
# spanning both URL shapes, so locale switches stay on the
# WebSocket. All emitted before the publishing catch-all so
# `//:locale/...` paths are never shadowed.
unquote(generate_public_live_routes(url_prefix, pattern))
unquote(generate_admin_routes(url_prefix, pattern))
unquote(generate_authenticated_live_routes(url_prefix, pattern))
# External route modules with public routes
unquote_splicing(external_public_routes)
# Publishing internal-prefix scope + call/2 override (when publishing is installed)
unquote(publishing_routing)
end
end
# Build the publishing routing-strategy AST. Returns `quote do end` (no-op)
# when publishing is not installed, so the macro stays a single-shape
# expansion without compile-time conditionals leaking into the host module.
#
# `apply/3` is the idiomatic dodge for the compile-time "undefined function"
# warning when calling into an optional dep — the `Code.ensure_loaded?/1`
# guard above is the runtime correctness check; `apply/3` shields the
# compiler's static-resolution pass. (Variable indirection like
# `mod = PhoenixKitPublishing.RouterDispatch; mod.fun()` does NOT
# shield the warning — Elixir's compiler tracks the binding's value
# and still emits `UndefinedFunctionError` warnings on the dispatch.
# Verified empirically; `apply/3` is the only escape valve for this
# specific shape.) Drop the `apply/3` calls once publishing becomes
# a required dep (it isn't, by design — installs without publishing
# should compile without it on the system).
#
# The credo `Refactor.Apply` warnings on the `apply/3` calls below are
# intentional — see comment above for why the variable-indirection
# alternative doesn't work.
#
# The `apply/3` calls also have a runtime resolution path: the host BEAM
# compiles the macro expansion (containing literal `apply` calls into
# `RouterDispatch`) and resolves them at request time. If a host removes
# publishing from their deps without recompiling core, the cached BEAM
# would `UndefinedFunctionError` at the next request. This is covered
# by the `__mix_recompile__?/0` mechanism injected by `phoenix_kit_routes/0`
# below — the host router's recompile-trigger hash includes the
# discovered module set, so removing publishing flips the hash and
# forces a recompile. The `apply` calls drop out of the regenerated AST.
@doc false
defp compile_publishing_routing(url_prefix) do
if Code.ensure_loaded?(PhoenixKitPublishing.RouterDispatch) do
# credo:disable-for-next-line Credo.Check.Refactor.Apply
internal_prefix = apply(PhoenixKitPublishing.RouterDispatch, :internal_prefix, [])
# credo:disable-for-next-line Credo.Check.Refactor.Apply
localized_segment = apply(PhoenixKitPublishing.RouterDispatch, :localized_segment, [])
# credo:disable-for-next-line Credo.Check.Refactor.Apply
root_segment = apply(PhoenixKitPublishing.RouterDispatch, :root_segment, [])
internal_scope_path =
case url_prefix do
"/" -> "/" <> internal_prefix
prefix -> prefix <> "/" <> internal_prefix
end
localized_sub = "/" <> localized_segment
root_sub = "/" <> root_segment
quote do
pipeline :phoenix_kit_publishing_internal do
plug PhoenixKitPublishing.RouterDispatch, :restore_path
end
scope unquote(internal_scope_path), PhoenixKit.Modules.Publishing.Web do
pipe_through [
:browser,
:phoenix_kit_auto_setup,
:phoenix_kit_locale_validation,
:phoenix_kit_optional_scope,
:phoenix_kit_publishing_internal
]
# Localized form — URL had a leading locale; bind :language + :group.
scope unquote(localized_sub) do
get "/:language/:group", Controller, :show
get "/:language/:group/*path", Controller, :show
end
# Non-localized form — URL had no leading locale; bind :group only.
# Without this discriminator scope, the localized routes above would
# also match a 2-segment internal path (Phoenix first-match-wins),
# binding `language=` and `group=` — which
# then 404s in the controller because the post slug isn't a group.
scope unquote(root_sub) do
get "/:group", Controller, :show
get "/:group/*path", Controller, :show
end
end
# Override Phoenix.Router's call/2 (defoverridable from `use Phoenix.Router`).
# Path-rewrites publishing-bound URLs before super() runs the matcher.
# The workspace's url_prefix is threaded in so the dispatch keeps
# working when PhoenixKit is mounted under a non-root path (e.g.
# `/phoenix_kit`) — without this, the dispatch's prepended segments
# land at the head of path_info instead of after the prefix, and
# nothing matches the registered internal routes.
# See PhoenixKitPublishing.RouterDispatch for the rationale.
def call(conn, opts) do
conn =
case PhoenixKitPublishing.RouterDispatch.maybe_rewrite(conn, unquote(url_prefix)) do
{:rewrite, rewritten} -> rewritten
:pass -> conn
end
super(conn, opts)
end
end
else
quote do
end
end
end
def init(opts) do
opts
end
def call(conn, :phoenix_kit_auto_setup) do
# Add backward compatibility for layouts that use render_slot(@inner_block)
Plug.Conn.assign(conn, :inner_block, [])
end
end