Route CI nav visibility through the handler, not the template
Templates render data; auth decisions belong at construction time.
Assisted-by: Claude Sonnet 4.6 via Claude Code
diff --git a/quire-server/src/bin/quire/main.rs b/quire-server/src/bin/quire/main.rs
index 149e36a..18ac4ad 100644
--- a/quire-server/src/bin/quire/main.rs
+++ b/quire-server/src/bin/quire/main.rs
@@ -147,14 +147,17 @@ async fn main() -> Result<()> {
let web_routes = {
let public = quire::quire::web::public_router(quire.clone());
let ci = quire::quire::web::ci_router(quire.clone());
- let ci = if dev {
- ci
- } else {
- ci.layer(axum::middleware::from_fn(
- quire::quire::web::auth::require_auth,
+ let ci = ci.layer(axum::middleware::from_fn(
+ quire::quire::web::auth::require_auth,
+ ));
+ let merged = public.merge(ci);
+ if dev {
+ merged.layer(axum::middleware::from_fn(
+ quire::quire::web::auth::inject_dev_user,
))
- };
- public.merge(ci)
+ } else {
+ merged
+ }
};
let api_routes = quire::quire::web::api::router(quire.clone());
commands::serve::run(&quire, web_routes, api_routes).await?
diff --git a/quire-server/src/quire/web/auth.rs b/quire-server/src/quire/web/auth.rs
index 01117ae..82f51cf 100644
--- a/quire-server/src/quire/web/auth.rs
+++ b/quire-server/src/quire/web/auth.rs
@@ -1,9 +1,35 @@
-//! Auth middleware for the web view.
+//! Auth middleware and extractor for the web view.
+use std::convert::Infallible;
+
+use axum::extract::FromRequestParts;
use axum::http::StatusCode;
+use axum::http::request::Parts;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
+/// Extractor that resolves to `true` when the `Remote-User` header is present.
+pub struct Auth(pub bool);
+
+impl<S: Send + Sync> FromRequestParts<S> for Auth {
+ type Rejection = Infallible;
+
+ async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
+ Ok(Auth(parts.headers.contains_key("Remote-User")))
+ }
+}
+
+/// Dev-only middleware that injects a synthetic `Remote-User` header so the
+/// `Auth` extractor behaves as if a real user is present.
+#[cfg(feature = "dev")]
+pub async fn inject_dev_user(mut request: axum::extract::Request, next: Next) -> Response {
+ request.headers_mut().insert(
+ "Remote-User",
+ axum::http::HeaderValue::from_static("dev"),
+ );
+ next.run(request).await
+}
+
/// Middleware that rejects unauthenticated requests.
///
/// CI routes require auth per the access matrix in PLAN.md.
diff --git a/quire-server/src/quire/web/handlers/ci.rs b/quire-server/src/quire/web/handlers/ci.rs
index 5d90431..fc815c6 100644
--- a/quire-server/src/quire/web/handlers/ci.rs
+++ b/quire-server/src/quire/web/handlers/ci.rs
@@ -7,6 +7,7 @@ use axum::response::{IntoResponse, Response};
use super::super::db;
use super::super::templates::{
Crumb, DetailJob, DetailRun, DetailShEvent, RunDetailTemplate, RunListRow, RunListTemplate,
+ nav_sections,
};
use super::git::RepoView;
use super::{render, render_error};
@@ -60,12 +61,12 @@ pub async fn run_list(State(quire): State<Quire>, AxumPath(repo): AxumPath<Strin
.collect();
let tmpl = RunListTemplate {
+ sections: nav_sections(&repo_display, "ci", true),
repo: repo_display,
crumbs: vec![],
runs: template_runs,
bookmarks,
tags,
- active_section: "ci".to_string(),
};
render(&tmpl)
}
@@ -210,6 +211,7 @@ pub async fn run_detail(
Crumb::new(detail_run.sha_short()),
];
let tmpl = RunDetailTemplate {
+ sections: nav_sections(&repo_display, "ci", true),
repo: repo_display,
crumbs,
run: detail_run,
@@ -217,7 +219,6 @@ pub async fn run_detail(
quire_ci_log,
bookmarks,
tags,
- active_section: "ci".to_string(),
};
render(&tmpl)
}
diff --git a/quire-server/src/quire/web/handlers/repo.rs b/quire-server/src/quire/web/handlers/repo.rs
index c53d63e..175273c 100644
--- a/quire-server/src/quire/web/handlers/repo.rs
+++ b/quire-server/src/quire/web/handlers/repo.rs
@@ -5,13 +5,18 @@ use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::response::Response;
+use super::super::auth::Auth;
use super::super::db;
-use super::super::templates::{RepoHomeTemplate, RunListRow};
+use super::super::templates::{RepoHomeTemplate, RunListRow, nav_sections};
use super::git::RepoView;
use super::render;
use crate::Quire;
-pub async fn repo_home(State(quire): State<Quire>, AxumPath(repo): AxumPath<String>) -> Response {
+pub async fn repo_home(
+ State(quire): State<Quire>,
+ auth: Auth,
+ AxumPath(repo): AxumPath<String>,
+) -> Response {
let repo_display = repo.trim_end_matches(".git").to_string();
let repo_name = db::resolve_repo_name(&repo);
let git_repo = match quire.repo(&repo_name) {
@@ -52,6 +57,7 @@ pub async fn repo_home(State(quire): State<Quire>, AxumPath(repo): AxumPath<Stri
.unwrap_or_default();
let tmpl = RepoHomeTemplate {
+ sections: nav_sections(&repo_display, "overview", auth.0),
repo: repo_display,
crumbs: vec![],
head,
@@ -60,7 +66,6 @@ pub async fn repo_home(State(quire): State<Quire>, AxumPath(repo): AxumPath<Stri
tags,
recent_runs,
recent_changes,
- active_section: "overview".to_string(),
};
render(&tmpl)
}
diff --git a/quire-server/src/quire/web/handlers/tree.rs b/quire-server/src/quire/web/handlers/tree.rs
index dea0b20..a633aa0 100644
--- a/quire-server/src/quire/web/handlers/tree.rs
+++ b/quire-server/src/quire/web/handlers/tree.rs
@@ -4,24 +4,30 @@ use axum::extract::{Path as AxumPath, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
+use super::super::auth::Auth;
use super::super::db;
-use super::super::templates::{Crumb, TreeEntry, TreeEntryKind, TreeTemplate};
+use super::super::templates::{Crumb, TreeEntry, TreeEntryKind, TreeTemplate, nav_sections};
use super::git::RepoView;
use super::render;
use crate::Quire;
-pub async fn tree_view(State(quire): State<Quire>, AxumPath(repo): AxumPath<String>) -> Response {
- tree_at_path(quire, repo, String::new()).await
+pub async fn tree_view(
+ State(quire): State<Quire>,
+ auth: Auth,
+ AxumPath(repo): AxumPath<String>,
+) -> Response {
+ tree_at_path(quire, repo, String::new(), auth.0).await
}
pub async fn tree_view_path(
State(quire): State<Quire>,
+ auth: Auth,
AxumPath((repo, path)): AxumPath<(String, String)>,
) -> Response {
- tree_at_path(quire, repo, path).await
+ tree_at_path(quire, repo, path, auth.0).await
}
-async fn tree_at_path(quire: Quire, repo: String, path: String) -> Response {
+async fn tree_at_path(quire: Quire, repo: String, path: String, authed: bool) -> Response {
let repo_display = repo.trim_end_matches(".git").to_string();
let repo_name = db::resolve_repo_name(&repo);
let git_repo = match quire.repo(&repo_name) {
@@ -57,11 +63,11 @@ async fn tree_at_path(quire: Quire, repo: String, path: String) -> Response {
};
let tmpl = TreeTemplate {
+ sections: nav_sections(&repo_display, "tree", authed),
repo: repo_display,
crumbs,
bookmarks,
tags,
- active_section: "tree".to_string(),
path,
bookmark: tree_data.bookmark,
sha_short: tree_data.sha_short,
diff --git a/quire-server/src/quire/web/templates.rs b/quire-server/src/quire/web/templates.rs
index 20b9c8d..2fa1db5 100644
--- a/quire-server/src/quire/web/templates.rs
+++ b/quire-server/src/quire/web/templates.rs
@@ -9,6 +9,39 @@ fn pkg_version() -> &'static str {
env!("QUIRE_VERSION")
}
+/// A section nav link in the repo tab bar.
+pub struct SectionLink {
+ pub label: &'static str,
+ pub href: String,
+ pub active: bool,
+}
+
+/// Build the section nav links for a repo page.
+///
+/// CI is included only when `authed` — the decision belongs here, not in the
+/// template.
+pub fn nav_sections(repo: &str, active: &str, authed: bool) -> Vec<SectionLink> {
+ let mut sections = vec![
+ SectionLink { label: "overview", href: format!("/{repo}"), active: active == "overview" },
+ SectionLink { label: "tree", href: format!("/{repo}/tree"), active: active == "tree" },
+ SectionLink { label: "log", href: format!("/{repo}/log"), active: active == "log" },
+ SectionLink {
+ label: "bookmarks",
+ href: format!("/{repo}/bookmarks"),
+ active: active == "bookmarks",
+ },
+ SectionLink { label: "tags", href: format!("/{repo}/tags"), active: active == "tags" },
+ ];
+ if authed {
+ sections.push(SectionLink {
+ label: "ci",
+ href: format!("/{repo}/ci"),
+ active: active == "ci",
+ });
+ }
+ sections
+}
+
/// A navigation breadcrumb entry.
///
/// When `href` is `Some`, the crumb renders as a clickable link.
@@ -43,7 +76,7 @@ pub struct RunListTemplate {
pub runs: Vec<RunListRow>,
pub bookmarks: Vec<BookmarkRow>,
pub tags: Vec<TagRow>,
- pub active_section: String,
+ pub sections: Vec<SectionLink>,
}
impl RunListTemplate {
@@ -104,7 +137,7 @@ pub struct RunDetailTemplate {
pub quire_ci_log: String,
pub bookmarks: Vec<BookmarkRow>,
pub tags: Vec<TagRow>,
- pub active_section: String,
+ pub sections: Vec<SectionLink>,
}
impl RunDetailTemplate {
@@ -246,7 +279,7 @@ pub struct RepoHomeTemplate {
pub tags: Vec<TagRow>,
pub recent_runs: Vec<RunListRow>,
pub recent_changes: Vec<ChangeRow>,
- pub active_section: String,
+ pub sections: Vec<SectionLink>,
}
impl RepoHomeTemplate {
@@ -373,7 +406,7 @@ pub struct TreeTemplate {
pub crumbs: Vec<Crumb>,
pub bookmarks: Vec<BookmarkRow>,
pub tags: Vec<TagRow>,
- pub active_section: String,
+ pub sections: Vec<SectionLink>,
/// Current directory path relative to repo root ("" = root).
pub path: String,
/// Active bookmark name (e.g. "main").
diff --git a/quire-server/static/style.css b/quire-server/static/style.css
index ceea360..0de745e 100644
--- a/quire-server/static/style.css
+++ b/quire-server/static/style.css
@@ -801,6 +801,7 @@ pre code[data-lang] {
border-right: 1px solid var(--rule);
min-width: 0;
padding-top: var(--space-xs);
+ padding-bottom: var(--space-xs);
}
.tree-row {
@@ -866,7 +867,7 @@ pre code[data-lang] {
/* Tree sidebar — right column, recent commit log */
.tree-sidebar {
- padding: var(--space-2xs) var(--space-xl) 0 var(--space-l);
+ padding: var(--space-2xs) var(--space-s) var(--space-2xs) var(--space-s);
font-family: var(--font-mono);
font-size: 12.5px;
min-width: 0;
@@ -966,8 +967,23 @@ pre code[data-lang] {
.tree-sidebar {
border-top: 1px solid var(--rule);
- padding: var(--space-s) var(--space-xl) 0;
+ padding: var(--space-s) var(--space-s) var(--space-s);
}
+
+ .tree-path-crumb,
+ .tree-commit-strip,
+ .tree-footer {
+ padding-left: var(--space-s);
+ padding-right: var(--space-s);
+ }
+
+ .tree-row {
+ grid-template-columns: 18px minmax(0, 1fr) max-content;
+ padding-left: var(--space-s);
+ padding-right: var(--space-s);
+ }
+
+ .tree-msg { display: none; }
}
/* ── Mobile (≤768px) ───────────────────────────────────────────── */
diff --git a/quire-server/templates/_repo_section_nav.html b/quire-server/templates/_repo_section_nav.html
index f8ada14..bb5d246 100644
--- a/quire-server/templates/_repo_section_nav.html
+++ b/quire-server/templates/_repo_section_nav.html
@@ -1,6 +1,2 @@
-<a class="section-link {% if active_section == "overview" %}section-link--active{% endif %}" href="/{{ repo }}">overview</a>
-<a class="section-link {% if active_section == "tree" %}section-link--active{% endif %}" href="/{{ repo }}/tree">tree</a>
-<a class="section-link {% if active_section == "log" %}section-link--active{% endif %}" href="/{{ repo }}/log">log</a>
-<a class="section-link {% if active_section == "bookmarks" %}section-link--active{% endif %}" href="/{{ repo }}/bookmarks">bookmarks{% if !bookmarks.is_empty() %} <span class="section-count">{{ bookmarks.len() }}</span>{% endif %}</a>
-<a class="section-link {% if active_section == "tags" %}section-link--active{% endif %}" href="/{{ repo }}/tags">tags{% if !tags.is_empty() %} <span class="section-count">{{ tags.len() }}</span>{% endif %}</a>
-<a class="section-link {% if active_section == "ci" %}section-link--active{% endif %}" href="/{{ repo }}/ci">ci</a>
+{% for s in sections %}<a class="section-link{% if s.active %} section-link--active{% endif %}" href="{{ s.href }}">{{ s.label }}</a>
+{% endfor %}
\ No newline at end of file