This version is in beta. Some features may change before release.

user in templates

Inject the current user into every HTML template's context (`is_authenticated`, `is_staff`, and the full serialized AuthUser) via one builder method on AuthPlugin.

With one builder call, umbral injects the current user into every HTML template's context, so templates can write {% if user.is_authenticated %} and {% if user.is_staff %} directly. It's opt-in.

The one-line opt-in

Code
rust
.plugin(
AuthPlugin::new()
.with_default_routes()
.with_user_in_templates() // ← here
)

That's it. The user global is always present in every umbral::templates::render(...) call (handler templates, plugin templates, error pages) because umbral-core injects it unconditionally. What .with_user_in_templates() changes is what authenticated requests see: with the opt-in, a logged-in request gets the full serialized user row; without it, every request (logged-in or not) gets the anonymous sentinel.

What user looks like in your template

Anonymous request (no session cookie / expired session / user_id IS NULL on the session row):

Code
jinja
{% if user.is_authenticated %}
...
{% else %}
<a href="/login/">Sign in</a> {# ← this branch fires #}
{% endif %}

user is { "is_authenticated": false, "is_staff": false, "is_superuser": false }. These three boolean keys are the only shape an anonymous user carries, so any {% if user.is_staff %} / {% if user.is_superuser %} gate evaluates to false instead of throwing.

Authenticated request: user is the full serialized AuthUser (every field on the model) plus "is_authenticated": true:

Code
jinja
{% if user.is_authenticated %}
<span>Hi, {{ user.username }}</span>
{% if user.is_staff %}
<a href="/admin/">Admin</a> {# ← gates the staff link #}
{% endif %}
{% if user.is_superuser %}
<a href="/internal/">Internal tools</a>
{% endif %}
{% endif %}

Every column on AuthUser is accessible: user.email, user.date_joined, user.last_login, and so on. A custom UserModel (the AuthPlugin::<MyUser> form) needs its own template wiring. See Custom user models below.

Why opt-in

Each request that touches a template pays one DB read (cookie to session to user row). A REST-only service has no templates and nothing to gain, so leaving the middleware off keeps the per-request cost where it belongs. HTML-heavy apps turn it on once at boot and forget about it.

What's happening under the hood

.with_user_in_templates() flips a flag on the plugin. At build time, AuthPlugin::wrap_router (a Plugin::wrap_router override) wraps the whole merged router in axum::middleware::from_fn(user_context_layer):

Code
text
Request → [user_context_layer]
current_user(&headers).await
serialize(user) merged with {is_authenticated: true}
umbral::templates::with_current_user(value, next.run(req)).await
[handler] → umbral::templates::render(...) → sees `user` in ctx

The middleware lives at plugins/umbral-auth/src/session_user.rs:263. It uses a tokio task-local (CURRENT_USER) scoped for the duration of the request so the rendered context is per-request, not global.

Info

user is always defined in templates: umbral-core's renderer injects it unconditionally. Without .with_user_in_templates(), it's the anonymous sentinel { is_authenticated: false, is_staff: false, is_superuser: false } for every request, even authenticated ones, because no middleware populates the per-request user. So {% if user.is_staff %} evaluates cleanly (to false) rather than throwing an "undefined value" 500. What .with_user_in_templates() adds is the live, authenticated shape: it mounts the middleware that replaces the sentinel with the real serialized user for logged-in requests.

Common patterns

Code
jinja
{% if user.is_staff %}
<a href="/admin/">Admin</a>
{% endif %}

Show a different greeting based on auth state

Code
jinja
{% if user.is_authenticated %}
Welcome back, {{ user.username }}.
{% else %}
<a href="/login/">Sign in</a> to track your orders.
{% endif %}

Branch on any AuthUser column

Code
jinja
{% if user.is_authenticated and user.is_superuser %}
<div class="banner banner-warning">
You're signed in as a superuser. Anything you do here is logged.
</div>
{% endif %}

Use a custom display name from your own table

The serialized shape carries every column on AuthUser. If you've added a display_name column via a custom user model, it's available the same way:

Code
jinja
<span>{{ user.display_name or user.username }}</span>

with_user_in_templates() expands the serialized user with its related single objects, up to two hops, so a template can dot-walk into them without the handler pre-resolving anything:

Code
jinja
{# reverse-OneToOne: a Customer with a UNIQUE FK to AuthUser #}
<p>Loyalty points: {{ user.customer.loyalty_points }}</p>
 
{# two hops: reverse-OneToOne, then a forward FK on that child #}
<p>City: {{ user.customer.address.city }}</p>

How the keys are named:

  • Forward FK — a foreign-key column resolves to its full target row under the column's own name (user.<fk_field>.<column>).
  • Reverse-OneToOne — a child model with a UNIQUE FK pointing at auth_user is injected under the child table's name (Customer { user: ForeignKey<AuthUser> (unique) }user.customer). An ambiguous match (two UNIQUE FKs to the user, e.g. primary_user + backup_user) is skipped — there's no single right answer for which becomes user.customer.

The walk is depth-bounded at 2 hops (user.customer.address is the last resolvable step) with (table, pk) cycle detection, and it costs one query per resolved relation per request — the honest price of "templates get relations for free." Sparse graphs (the common case) add 1–3 queries; deeper or wider graphs hit the cap and stop.

Reverse-FK one-to-many lists: opt-in

The list of children pointing at the user through a non-unique FK ({{ user.order_set }}) is not auto-expanded — it's an unbounded per-request query (a user with 50k orders would otherwise load all of them into every render). You opt each list in explicitly with expand_list::<Child>():

Code
rust
AuthPlugin::<AuthUser>::default()
.with_user_in_templates()
.expand_list::<Order>() // surfaces `user.order_set`
Code
jinja
{% for o in user.order_set %}
<li>{{ o.total }}</li>
{% endfor %}

The list is injected under user.<child_table>_set (the same _set suffix as the Rust reverse-FK accessor) and is capped — at most 20 rows, ordered by primary key — so it can never become an unbounded render cost. Its items are the flat child rows (their own columns), not further-expanded. Need a filtered, ordered, or larger slice? Resolve it in the handler instead:

Code
rust
let orders = Order::objects().filter(order::USER.eq(&user.0.id)).limit(50).fetch().await?;
render("me.html", &context!(username, orders))

Everything else — columns on AuthUser itself (is_authenticated, is_staff, username, email, date_joined, …), the forward-FK / reverse-O2O relations above, and any expand_list you declared — is available on user directly. M2M sets and computed fields still have to be resolved in the handler.

Custom user models

The default wiring is AuthPlugin::new().with_user_in_templates(). The middleware is hard-bound to AuthUser because the serialization step uses its concrete Serialize impl. Apps using a custom UserModel build their own middleware against the building blocks:

  • umbral_sessions::current_session(&headers)Option<SessionRow>
  • current_session.user_id → your custom user's PK
  • Your own MyUser::objects().filter(...).first().await
  • umbral::templates::with_current_user(my_user_value, next).await to push it into the per-request task-local

See the source of user_context_layer in plugins/umbral-auth/src/session_user.rs for a 30-line reference impl.

When to use this vs. extractors

For handler-level user access, extractors are still the right shape:

Code
rust
async fn dashboard(LoggedIn(user): LoggedIn<AuthUser>) -> Result<Html<String>, StatusCode> {
let body = render("dashboard.html", &context!(username => user.username))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Html(body))
}

The extractor and the template global serve different purposes:

Use caseTool
Handler logic needs the user (DB queries, branching)LoggedIn<AuthUser> / OptionalUser extractors
Template needs to render conditionally based on user.with_user_in_templates() global
BothBoth. They don't conflict. The extractor reads the user fresh from the session per-handler; the global is populated by the middleware that runs before any handler

Pages that include partials (the navbar in a wrapper, a sidebar everyone sees) are why the template global exists: every handler shouldn't have to thread user into every template's context for the wrapper to render correctly.