Skip to main content

Admin panel and tenant portal

Converger ships two browser UIs, both Phoenix LiveView:

  • the admin panel at /admin, for operators (admin users, behind ADMIN_IP_WHITELIST and a session, see Security);
  • the tenant portal at /portal, for the users of one tenant (tenant name, email and password).

They share one design system: Tailwind CSS v4 with daisyUI 5 components and heroicons, the Phoenix 1.8 default stack (ADR-0060).

Pages​

Admin panelTenant portalWhat it shows
/admin/portalDashboard: counts, delivery status totals, channel health (live); every tile links to its filtered list
/admin/tenantsTenants, API key reveal-once and rotation, status, delivery tier; Edit for retention, tier, alert webhook and rate limit overrides
/admin/channels/portal/channelsChannels, per-type configuration, health sparkline, circuit breaker pause/resume, Send test, Rotate secret, Client access (test token, publishable key)
/admin/conversations/portal/conversationsConversations; transcript with live tail, delivery badges, participant panel, close/reopen
/admin/routing_rules/portal/routing_rulesRouting rules and the dry run (/admin/routing_rules/dry_run)
/admin/deliveries/portal/deliveriesDead letters and replay, CSV export (Deliveries page)
/admin/webhooks/portal/webhooksPlatform event webhooks: subscriptions, secret rotation, paged event log
/admin/audit_logsAudit log
/admin/users, /admin/tenant_users/portal/usersAdmin users, tenant users, 2FA reset, sign out
/admin/security/portal/securityOwn 2FA, password and active sessions (ADR-0058)
/admin/obanOban Web job dashboard (Delivery)

Lists: search, filters, sort and Load more​

Every list page has a filter bar and loads rows in pages of default_limit (50, config :converger, :pagination) with Load more, using keyset pagination (ADR-0018). The filters are part of the URL, so a filtered view can be bookmarked or shared; the dashboard links use this (for example /admin/deliveries?status=failed). Sort is Newest first (default) or Oldest first by creation time (deliveries: by the last status change). Search is case-insensitive and matches a part of the text; % and _ are matched literally.

ListSearch matchesOther filters
Tenantsname, or the exact tenant idstatus
Channelsname, typetenant (admin), mode, status
Conversationsthe exact conversation id, or a part of the participant's external id or display nametenant (admin), channel, status
Routing rulesnametenant (admin), enabled
Deliveriesthe exact activity id, or a part of last_errortenant (admin), channel, status, from/to date
Audit logsthe exact resource id, or a part of the actor idtenant, actor type, action, resource type, from/to date
Admin usersname, emailrole, status
Tenant usersname, emailtenant (admin), role, status
Webhook subscriptionsa part of the URL

The CSV export and Retry all failed on the Deliveries page use the same filters, including the search.

Channels​

  • Send test posts a text message activity from bot into a dedicated conversation of the channel (metadata.converger_test: true, created on first use and reused), through the normal pipeline: the channel's outbox, retries and middleware apply, and so do its routing rules, so rule targets receive it too. The delivery status updates live in the panel. Inbound-only and inactive channels cannot send. Providers that need a recipient (WhatsApp, Telegram, SMS and similar) record a failed delivery, because the test conversation has no participant. Audited as send_test_message.
  • Rotate secret replaces the channel secret. The new one is shown once; the old one stops working at once (inbound signatures, tokens/generate), so update the integration right away. Open sockets stay connected. Audited as rotate_secret, with the secret values redacted.
  • The health column draws the failure rate of the last 20 health checks (channel health) as a sparkline, with a text summary for screen readers.
  • Pause / Resume holds and releases the channel's deliveries (circuit breaker).

Client access​

Client access on a channel row opens a panel with what a chat client needs (#194, #195). Admin roles super_admin and admin; portal roles owner and admin.

  • Base URL and WebSocket (/socket/converger/v1), as the browser reaches this server, with copy buttons.
  • Issue test token mints the token POST /api/v1/converger/tokens/generate would return with the channel secret, for the end user id you enter (default test-user; blank for none), so you can try the channel from a chat client (for example chativa's sandbox, Token mode) without handling the secret. It expires in 30 minutes. Inactive channels cannot issue one. Audited as test_token with the user id and the token's jti, never the token.
  • Publishable key: enter the allowed origins (one per line, https://shop.example.com or https://*.example.com) and Save and enable key; the key (cvg_pk_...) is shown with a copy button and can be shown again any time (it is not a secret). Save origins updates the list, Rotate key issues a new key (the old one stops at once) and Disable key turns it off. Audited as enable_publishable_key, update_allowed_origins, rotate_publishable_key and disable_publishable_key. See publishable keys.

Conversations​

The transcript is live: new activities and delivery status changes appear without reloading (the page subscribes to the conversation's PubSub topic), marked by a Live badge. Each activity shows its delivery status as text (pending, sent, delivered, read, failed). The participant panel shows the external id, display name, channel, opt-out date and profile metadata. Close and Reopen change the conversation's status; on the admin panel they are audited (close, reopen).

Rich messages: Implemented (#68)​

Typed messages (rich message vocabulary) are rendered natively on both transcripts, server-side with daisyUI (ConvergerWeb.MessageComponents), each tagged with its type:

typerendered as
text with actions, quick-reply, buttonsthe text and the actions as chips (not clickable answers; a url action is a link)
card, carouselcards with title, subtitle, body, image link and buttons; a carousel scrolls horizontally
image, file, video, audioa link to the media with its name or alt text, caption, type, size, dimensions and duration
locationname, address, coordinates and an OpenStreetMap link
contactname, organization, phone numbers and e-mail addresses
tool_calla collapsible trace: tool name, status badge, params and result as JSON
genuithe stream id and the chunk: component name and props, text content, or event name
interruptthe prompt (payload.title), the actions as chips, the payload, and an open or resolved badge (resolved once an interrupt_resolved with the same id is in the transcript, also live)
interrupt_resolved, skilla short note with the answer; a skill badge with its status
any other typeits data.text, else its payload as JSON

Plain messages show their text as before. The payload is client input, so the renderer escapes everything (no raw HTML), only links http/https URLs and same-origin /api/ paths (javascript:, data: and other schemes are refused even though the schemas already reject them on write), and cuts JSON previews at 2,000 characters. Media are never loaded inline: the admin Content-Security-Policy (img-src 'self') does not load third-party content, which also keeps tracking pixels out, and attachment URLs need an API token. The issue suggested embedding chativa's web component for this; that needs chativa's JavaScript in the asset build, which has no Node and no CDN (ADR-0060), so the transcripts render server-side instead.

Permissions​

ActionAdmin panelTenant portal
Browse every pageall rolesall roles (own tenant)
Create, edit, delete tenants, channels, routing rules; rotate secrets; send tests; pause/resume; client access (test tokens, publishable keys)super_admin, adminchannels: owner, admin (enable/disable also member); routing rules: enable/disable for owner, admin, member
Close / reopen conversationssuper_admin, adminowner, admin, member
Manage usersadmin users: super_admin; tenant users: super_admin, adminowner, admin

viewers (admin and portal) are read-only: the forms and action buttons are hidden, and the server refuses the action if it is sent anyway. Deliveries and webhooks have their own matrix, see Deliveries page and Platform event webhooks.

Layout and themes​

Every page has the same frame: a sidebar with the section's navigation (a drawer on small screens), a top bar with the tenant (portal), the signed-in user and role, the theme toggle and Logout, and toast-style flash messages. The login and password pages use a centered card.

The theme toggle switches between system (follows the operating system's light or dark preference), light and dark. The choice is stored in the browser's localStorage (phx:theme) and applied before the page is painted, so there is no flash of the wrong theme.

Assets​

The CSS and JavaScript are built from assets/ into priv/static/assets/ (not committed) by the standalone tailwind and esbuild binaries, which the Hex packages of the same name download on first use. Node.js and npm are not needed, and nothing is loaded from a CDN at runtime.

CommandWhat it does
mix assets.setupDownloads the tailwind and esbuild binaries (also run by mix setup)
mix assets.buildBuilds app.css and app.js for development (also run by mix setup)
mix assets.deployBuilds minified assets and digests them (phx.digest); used by the Dockerfile
mix phx.serverIn development, the endpoint watchers rebuild the assets on every change

The versions are pinned in config/config.exs (config :tailwind, version: and config :esbuild, version:), and heroicons and daisyUI in mix.exs (git tags). An air-gapped build needs the binaries from GitHub (tailwindlabs/tailwindcss releases) and the npm registry (@esbuild/<platform>), and the two git repositories, mirrored.

FilePurpose
assets/css/app.cssTailwind entry: sources, the heroicons plugin, daisyUI and the light/dark themes
assets/js/app.jsLiveSocket, phoenix_html (for method="delete" links and data-confirm) and the topbar
assets/vendor/The heroicons Tailwind plugin and topbar

Writing pages​

  • Wrap every LiveView's render/1 in <Layouts.app flash={@flash} nav={@nav}>. There is no implicit LiveView layout: a page without it has no navigation and shows no flash messages. nav is assigned by ConvergerWeb.Live.AuthHooks. Controller pages use <Layouts.auth flash={@flash} title="...">.
  • Use the components in ConvergerWeb.CoreComponents: <.header>, <.panel>, <.table> (works with stream/3), <.input>, <.button> (variant and size), <.badge value={status} /> (colored by value), <.stat>, <.empty_state>, <.icon name="hero-...">.
  • Style with Tailwind utilities and daisyUI's semantic colors (text-base-content/60, bg-base-200, text-error) so both themes work. No inline style= attributes.
  • Give every form with phx-change or phx-submit an id; use data-confirm for confirmation dialogs.

Security​

The :browser pipeline's Content Security Policy only allows same-origin scripts and styles, plus 'unsafe-inline' for the theme script and the Oban Web dashboard. See Security overview: browser headers.