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::<AuthUser>::default()
.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>
Warning

Reverse-OneToOne, reverse-FK, and forward-FK traversal in templates is not implemented yet. {{ user.profile.avatar }}, {{ user.customer.id }}, {{ user.order_set }}, {{ user.profile.address.city }}: none of these work today, regardless of how the relations are declared on the Rust side.

Why: user in templates is the JSON-serialized AuthUser. Relation accessors are Rust async methods that aren't serialized into the value (they'd need DB access to resolve, and minijinja is sync, can't .await). The cross-crate reverse-OneToOne accessor (e.g. user.customer().await? from orm/relationships) is a Rust-only API.

What to do today: resolve the relations you need in the handler and pass them into the template context explicitly:

Code
rust
let customer = user.0.customer().await?;
let customer_id = customer.as_ref().map(|c| c.id);
let loyalty = customer.as_ref().map(|c| c.loyalty_points).unwrap_or(0);
render("me.html", &context!(username, customer_id, loyalty))

Then in the template: {{ customer_id }}, {{ loyalty }}, flat keys, pre-resolved.

What's coming: planning/gaps2.md #14 captures the design space. The front-runner is an eager-prefetch declaration built on select_related / prefetch_related: the handler declares "this template will walk user.customer.address," the framework resolves those before rendering, and the template uses dot-notation against the pre-loaded values. No ETA; logged as a focused follow-on.

For now, user.<column> works only for columns on the AuthUser table itself: is_authenticated, is_staff, is_superuser, username, email, date_joined, etc. Anything that requires a SQL query to fetch (relations, computed fields, M2M sets) has to be resolved in the handler.

Custom user models

The default wiring is AuthPlugin::<AuthUser>::default().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.