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
.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):
{% 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:
{% 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):
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 ctxThe 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.
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
Hide a link behind a staff check
{% if user.is_staff %} <a href="/admin/">Admin</a>{% endif %}Show a different greeting based on auth state
{% 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
{% 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:
<span>{{ user.display_name or user.username }}</span>What user does NOT carry: related objects
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:
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).awaitto 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:
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 case | Tool |
|---|---|
| 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 |
| Both | Both. 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.
Related
- Login, logout, request.user: extractors for handler-level user access
- Users and passwords: the
AuthUsermodel + password hashing - Plugin trait: what
wrap_routeris and how middleware is contributed