Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Customizing the Panel

The pages at /ui are minijinja templates compiled into the binary. Any one of them can be replaced on disk without rebuilding, the same way notification templates work — an operator who has already overridden a notification should not have to learn a second scheme.

[admin]
template_dir = "/etc/acme-proxy/admin-templates"

Each name is looked for in that directory first and falls back to the compiled-in default. The override is per file, not per directory: a directory holding only layout.html restyles the chrome of every page and leaves the other fifty-five exactly as shipped.

Every template is compiled at startup. A broken override refuses to start, naming the file and the parse error, rather than serving a 500 the first time somebody opens that page.

The files

Paths are relative to template_dir, and are also how the templates refer to each other in {% extends %} and {% include %}.

FileWhat it is
layout.htmlThe chrome every full page extends: <head>, navigation, <body>
login.htmlSign-in. Standalone — extends nothing, and uses no JavaScript
mfa/challenge.htmlThe second sign-in step. Standalone and JavaScript-free for the same reason; branches on step between proving a code and setting one up
mfa/_setup.htmlThe setup key and the otpauth:// URI. Included by both the sign-in flow and the account page, so it renders no <form> of its own
mfa/_codes.htmlA fresh recovery set, the one time it exists in the clear
mfa/enrolled.htmlWhere a forced enrolment lands: the codes, then a link into the panel
account/index.html, account/_mfa.htmlThe operator’s own page, and the fragment every mutation on it swaps
account/_card.htmlThe second-factor card itself, with no id — so _codes.html can wrap it without nesting two elements carrying one
account/_enrol.html, account/_codes.htmlThe enrolment step, and the codes plus the refreshed card
account/_password.html, account/_password_card.htmlThe password-change swap target, and the form inside it
account/_contact.htmlWhere this operator’s own security notifications go
account/_sessions.htmlThis operator’s own live sessions, the swap target of revoking one
index.htmlThe overview: four counts and the endpoint list
partials/_flash.htmlThe inline banner every mutation’s answer renders
partials/_pager.htmlThe previous/next controls under a list
partials/_filter_meta.htmlThe tail of every list’s filter form: the loading indicator and the way back to the unfiltered list
partials/_sessions_table.htmlA table of live sessions, shared by the account page and an operator’s card
accounts/list.html, accounts/_table.htmlThe account list, and the table htmx swaps
accounts/detail.html, accounts/_card.htmlOne account, and the card every account mutation returns
orders/list.html, orders/_table.htmlThe order list
orders/detail.html, orders/_card.htmlOne order with its authorizations and challenges
expiring/list.html, expiring/_table.htmlThe expiry list, its window and profile filters, and the hidden-count line
eab/list.html, eab/_table.htmlThe credential list and the create form
eab/detail.html, eab/_card.htmlOne credential
eab/_created.htmlThe one-time HMAC secret
nonces/index.html, nonces/_panel.htmlThe nonce count and the sweep control
profiles/list.html, profiles/_table.htmlThe mounted endpoints
profiles/filter.htmlOne endpoint’s resolved access policy. No fragment: nothing on it swaps
audit/list.html, audit/_table.htmlThe audit trail
audit/detail.html, audit/_card.htmlOne audit row
jobs/list.html, jobs/_table.htmlThe background job queue
jobs/detail.html, jobs/_card.htmlOne job, and the card its cancel and run-now actions return
upstream_orders/list.html, upstream_orders/_table.htmlThe relay backend’s upstream orders. Read-only
upstream_orders/detail.html, upstream_orders/_card.htmlOne upstream order, cross-linked to its job
operators/list.html, operators/_table.htmlThe web admin’s operators
operators/detail.html, operators/_card.htmlOne operator and their live sessions, re-rendered by every mutation on the page

A file whose name starts with _ is a fragment: htmx swaps it on its own, so it must not contain <html> or <body>, and it must keep the id on its root element — that id is what the page’s hx-target points at.

Two things not to break

The extension is a security control

Every page template is named .html, and that is deliberate. minijinja decides auto-escaping from the template name, and the notify templates are named .j2 precisely so that escaping is off for them (an email body is not markup). Renaming a page template to .j2 — or adding a new one under a name minijinja does not recognise as HTML — turns an account contact or an EAB label into stored XSS.

The CSRF token has to stay on <body>

layout.html carries:

<body hx-headers='{"X-CSRF-Token": "{{ csrf_token }}"}'>

That attribute is the only route by which the token reaches a mutating request. A layout that drops it loses every write at once — which is the intended failure mode; a partial loss would be far harder to notice.

The same file sets three htmx options the other templates depend on:

<meta name="htmx-config"
      content='{"includeIndicatorStyles":false,"defaultSwapStyle":"outerHTML","responseHandling":[...]}'>

includeIndicatorStyles: false stops htmx injecting an inline <style> element that the Content-Security-Policy’s style-src 'self' would block — the rules it would have injected live in admin.css instead. responseHandling makes htmx swap non-2xx responses, without which a 409 conflict would fail silently instead of showing the operator a banner.

defaultSwapStyle: "outerHTML" means a response replaces the element it targets. Every fragment is written for that: its root element carries the same id as the swap target (accounts/_table.html is <div id="accounts-table">, the target of the accounts filter form). An override of a fragment must keep that root and its id, or the next swap on the page finds no target.

Context

Every full page gets csrf_token, user, can_write, nav (the active navigation item) and title, plus its own data; the fragment a mutation answers with gets the first three. Gate a control on {% if can_write %} — true for an operator or admin session — which is also false when absent, so a control never appears by accident. Timestamps are RFC 3339 strings, and the ago filter renders one as 3 h ago or in 5 d, or as nothing when it is not a timestamp:

{{ order.createdAt }} ({{ order.createdAt | ago }})
``` That data is the **same JSON the API returns** —
`render_account_json`, `render_order_detail_json`, `render_eab_json` — so `GET
/api/accounts/{id}` is an accurate description of what `account` holds in
`accounts/_card.html`. Lists additionally get `page` (`{items, total}`),
`pager`, `filters` and `profiles`.

A quick way to see a context in full is to render it:

```jinja
<pre>{{ account | tojson(indent=2) }}</pre>

Starting from the shipped version

The defaults are in the source tree under crates/admin/src/webadmin/templates/. Copy the one you want to change:

$ mkdir -p /etc/acme-proxy/admin-templates
$ cp crates/admin/src/webadmin/templates/layout.html /etc/acme-proxy/admin-templates/

Then send SIGHUP — see Reloading the Configuration. Templates are compiled up front, so a mistake fails the reload and the panel goes on serving the last set that worked; it never reaches a browser. The same compile happens at startup, where a mistake stops the process instead.

Stylesheet and scripts

admin.css and htmx.min.js are served from /ui/static/ and are not covered by template_dir — they are embedded assets, not templates. To restyle beyond what CSS variables allow, override layout.html and point its <link> at your own file. Note that the Content-Security-Policy is default-src 'none' with style-src 'self': a stylesheet must be served from this origin, and an inline <style> block or style= attribute will be blocked.