# Phoenix Integration AshMultiAccount provides seven Phoenix modules that handle session management, routing, controller actions, LiveView state, controller state, and UI components. This topic covers each module in detail. ## Plug `AshMultiAccount.Phoenix.Plug` ensures a session token UUID exists before any multi-account routes are hit. ```elixir pipeline :browser do plug :fetch_session plug :fetch_flash # ... other plugs ... plug AshMultiAccount.Phoenix.Plug end ``` The plug generates a UUID via `Ash.UUID.generate()` and stores it in the `"session_token"` session key. If a token already exists, the plug is a no-op. This runs on every request in the pipeline, so the token is always available when the controller needs it. ## LoadMultiAccount Plug `AshMultiAccount.Phoenix.LoadMultiAccount` resolves `@current_user` and `@primary_user` assigns for controller-rendered pages. It mirrors the logic of the LiveView hook but operates on `Plug.Conn`. ```elixir pipeline :browser do plug :fetch_session # ... other plugs ... plug AshMultiAccount.Phoenix.Plug plug AshMultiAccount.Phoenix.LoadMultiAccount, user_resource: MyApp.Accounts.User end ``` ### Configuration The plug requires a `:user_resource` option — it raises `ArgumentError` at compile time if omitted. It must run after `:fetch_session` and `AshMultiAccount.Phoenix.Plug` in the pipeline. ### Multi-Account Mode When `multi_account_session?` is true: 1. Loads the primary user via the `get_user_with_linked_accounts` action 2. Validates the primary user passes `active_check` 3. Resolves the current user from the session (overriding any JWT-based assign) 4. Assigns both `@current_user` and `@primary_user` ### Standard Mode When no multi-account session exists: 1. Gets `current_user` from conn assigns or the session 2. Loads configured `display_fields` 3. Sets `@primary_user` to `nil` ### Error Handling When the primary user is **not found** or **not active** (e.g., stale session after data reset), the plug clears the multi-account session keys and falls back to standard mode — resolving the current user from `conn.assigns` or the session. For unexpected errors (e.g., database failures), the plug assigns `nil` gracefully and lets the controller decide how to respond. ### When to Use LoadMultiAccount vs LiveHook - Use **LoadMultiAccount** for controller-rendered pages (`Plug.Conn` pipeline) - Use **LiveHook** for LiveView pages (`on_mount` hook) - Both can coexist in the same app — they read the same session keys ## Session Helpers `AshMultiAccount.Phoenix.Session` provides read/write functions for the three multi-account session keys. All read functions accept both `Plug.Conn` (controllers) and raw session maps (LiveView hooks). Write functions require `Plug.Conn`. Key functions: - `get_user_id/1` / `put_user_id/3` — reads/writes the `"user"` subject string - `get_primary_user_id/1` / `put_primary_user_id/2` — reads/writes `"primary_user_id"` - `get_session_token/1` / `put_session_token/2` — reads/writes `"session_token"` - `put_multi_account_session/3` — atomically sets both `"primary_user_id"` and `"session_token"` - `multi_account_session?/1` — returns `true` if both keys are present - `clear_multi_account_session/1` — removes multi-account keys (keeps `"user"`) The `"user"` key uses AshAuthentication's subject format: `"?id="`. The `put_user_id/3` function constructs this format; `get_user_id/1` parses it back to a plain UUID. The default short name is `"user"` — pass a third argument if your resource uses a different one. ## Router `AshMultiAccount.Phoenix.Router` provides the `multi_account_routes/3` macro. ```elixir use AshMultiAccount.Phoenix.Router scope "/", MyAppWeb do pipe_through :browser multi_account_routes MultiAccountController, MyApp.Accounts.User end ``` This generates three routes: | Method | Route | Action | Purpose | |--------|-------|--------|---------| | GET | `/link/p/:primary_user_id` | `:link_account` | Initiate linking or render auto-submit form | | POST | `/link/p/:primary_user_id` | `:link_account` | Complete account linking (creates record) | | GET | `/link/switch_to/:user_id` | `:switch_to_account` | Switch to a linked account | Custom paths are supported: ```elixir multi_account_routes MultiAccountController, MyApp.Accounts.User, link_path: "/accounts/link/:primary_user_id", switch_path: "/accounts/switch/:user_id" ``` ## Controller `AshMultiAccount.Phoenix.Controller` is a macro that injects `link_account/2` and `switch_to_account/2` into your controller. ```elixir defmodule MyAppWeb.MultiAccountController do use MyAppWeb, :controller use AshMultiAccount.Phoenix.Controller, user_resource: MyApp.Accounts.User end ``` ### Overridable Functions | Function | Default | Purpose | |----------|---------|---------| | `after_link_path/1` | Origin page (from session) or `"/"` | Redirect after successful link | | `after_switch_path/1` | Origin page (from Referer) or `"/"` | Redirect after successful switch | | `sign_in_path/2` | `"/sign-in?return_to=/link/p/:id"` | Where to send unauthenticated users | | `sign_out_path/1` | `"/sign-out"` | Where to send users on fatal errors | By default, both `after_link_path/1` and `after_switch_path/1` return the user to the page they were on when they started the action. For linking, the origin page is saved in the session at the start of the multi-step flow. For switching, the origin is read from the HTTP Referer header. Both fall back to `"/"` if the origin cannot be determined. ### link_account/2 Handles three cases: 1. **No authenticated user** — redirects to sign-in with a flash error 2. **Primary user matches current user** — sets up the multi-account session and redirects to sign-in (so the user can authenticate another account) 3. **Different user (GET)** — renders a minimal auto-submitting HTML form that immediately POSTs with a CSRF token. This preserves REST semantics (no record creation on GET) while working with the 302 redirect from the auth callback. 4. **Different user (POST)** — creates a LinkedAccount record tying the current user to the primary, then redirects to `after_link_path` The GET→auto-submit→POST pattern is the same approach used by OAuth, SAML, and payment gateway flows for state-changing operations after redirects. ### switch_to_account/2 Validates the switch is authorized, then: 1. Renews the session ID (`configure_session(renew: true)`) for session fixation protection 2. Writes the target user's ID to the session 3. Preserves the multi-account session keys 4. Redirects to `after_switch_path` Validation checks: - Target user exists and passes `active_check` - Both the current user and target user belong to the same linked account group ## LiveView Hook `AshMultiAccount.Phoenix.LiveHook` resolves `@current_user` and `@primary_user` on every LiveView mount. ```elixir live_session :authenticated, on_mount: [ {AshAuthentication.Phoenix.LiveSession, :load_from_session}, {AshMultiAccount.Phoenix.LiveHook, {:load_multi_account, MyApp.Accounts.User}} ] do # ... end ``` An optional keyword list can be passed as a third tuple element to configure the hook: ```elixir {AshMultiAccount.Phoenix.LiveHook, {:load_multi_account, MyApp.Accounts.User, sign_out_path: "/logout"}} ``` | Option | Default | Purpose | |--------|---------|---------| | `:sign_out_path` | `"/sign-out"` | Where to redirect on fatal errors (inactive user, load failure) | ### Multi-Account Mode When `multi_account_session?` is true (both `"primary_user_id"` and `"session_token"` are in the session): 1. Loads the primary user via the `get_user_with_linked_accounts` action 2. Validates the primary user passes `active_check` 3. Resolves the current user from the session's `"user"` key (not socket assigns, since AshAuthentication's JWT can't reflect switches) 4. Assigns both `@current_user` and `@primary_user` ### Standard Mode When no multi-account session exists: 1. Gets `current_user` from socket assigns or the session 2. Loads configured `display_fields` 3. Sets `@primary_user` to `nil` ### Error Handling - If the primary user is **not found** (e.g., stale session after data reset), the hook falls back to standard mode — resolving the current user from the session or socket assigns without multi-account context - If the primary user is **not active**, the hook halts with a flash error and redirects to the configured `sign_out_path` (default: `/sign-out`) - If loading fails with an unexpected error, the hook halts with a generic error message ## Components `AshMultiAccount.Phoenix.Components` provides a slot-based account switcher that imposes no styling. ```heex <:account :let={account}>
<.link :if={!account.current?} href={account.switch_url}> {account.user.name} {account.user.name} (primary)
<:add_account :let={url}> <.link href={url}>+ Add another account
``` ### Attributes | Attribute | Type | Default | Description | |-----------|------|---------|-------------| | `current_user` | map | required | Currently active user struct | | `primary_user` | map | `nil` | Primary account owner (nil = standard mode) | | `switch_path` | string | `"/link/switch_to"` | Base path for switch URLs | | `sign_in_path` | string | `"/sign-in"` | Sign-in path for add-account URL | | `link_path` | string | `"/link/p"` | Link path for add-account URL | ### Slot Data Each `:account` slot receives a map with: | Key | Type | Description | |-----|------|-------------| | `user` | struct | The user struct with display fields loaded | | `current?` | boolean | Whether this is the currently active user | | `primary?` | boolean | Whether this is the primary account | | `switch_url` | string | URL to switch to this account | The `:add_account` slot receives the URL string for initiating a new link (includes `return_to` parameter). ### Single-Account Mode When `primary_user` is `nil`, the component shows just the current user and the "add account" link. The first link click establishes the multi-account session.