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