The header knows who is here; my cases; controls that scale with the text
Test / test (pull_request) Successful in 1m12s

A session link top right on a site with sign-in, and on a page with
pictures the wordmark as its own bar over the strip. A list may be
narrowed to the signed-in person's own records ({user.email}, from the
session), and an attachment follows the list that shows its record.
Form controls are sized in em: the date picker's icon and the file
picker's button scale and take the field's colours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-30 17:32:41 +02:00
co-authored by Claude Opus 5.5
parent 9a96386444
commit 6b72ae568a
9 changed files with 318 additions and 85 deletions
+100 -59
View File
@@ -396,61 +396,9 @@ fn QuestionView(
landing=question_id == "/"
site=site.clone()
current_id=question_id.clone()
user=user.clone()
slideshow=question.slideshow.clone()
/>
// Pictures across the page: the full width of the screen, each
// as wide as its own shape at the strip's height, the caption
// laid over its foot. Inert until the Swiper bundle upgrades
// the elements; without it, a row that scrolls sideways.
{question
.slideshow
.clone()
.map(|show| {
let height = format!("--slideshow-height: {}svh", show.max_height);
view! {
// In the page itself, so the strip works from the
// server's HTML alone; the head's copy above covers
// arriving here by a link within the site.
<script src="/swiper-element-bundle.min.js" defer=true></script>
<swiper-container
class="slideshow"
style=height
slides-per-view="auto"
space-between="6"
grab-cursor="true"
free-mode="true"
mousewheel-force-to-axis="true"
keyboard="true"
navigation="true"
>
<For
each={move || show.slides.clone().into_iter().enumerate().collect::<Vec<_>>()}
key=|(_, slide)| slide.image.clone()
children=|(n, slide): (usize, crate::content::Slide)| {
let caption = slide.caption.clone();
// The first screenful loads with the
// page; the rest when swiped to.
let loading = if n < 4 { "eager" } else { "lazy" };
view! {
<swiper-slide>
<figure class="slide">
<img
src=slide.src()
alt=slide.caption.clone()
width=slide.width.map(|w| w.to_string())
height=slide.height.map(|h| h.to_string())
loading=loading
decoding="async"
/>
{(!caption.trim().is_empty()).then(|| view! { <figcaption>{caption}</figcaption> })}
</figure>
</swiper-slide>
}
.into_any()
}
/>
</swiper-container>
}
})}
// A page that is about somewhere says where, under the
// question: a still from this origin, and the place in words
// for anyone who cannot see it.
@@ -895,7 +843,15 @@ mod gesture {
}
#[component]
fn Hero(title: String, description: String, landing: bool, site: SiteConfig, current_id: String) -> impl IntoView {
fn Hero(
title: String,
description: String,
landing: bool,
site: SiteConfig,
current_id: String,
#[prop(optional_no_strip)] user: Option<User>,
#[prop(optional_no_strip)] slideshow: Option<crate::content::Slideshow>,
) -> impl IntoView {
// Only the landing page gets a piece - it's the one page a hero is
// actually "the" hero for; every other page gets the plain header.
// WHAT the piece is belongs to the content repo: site.yaml's
@@ -994,9 +950,37 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur
)
});
// Who is here, and the way in or out, at the top of every page of
// a site that has sign-in at all.
let session = site.sign_in.then(|| {
let redirect = current_id.clone();
match user {
Some(user) => view! {
<nav class="session">
<span class="session-name">{user.display_name}</span>
<a href="/auth/logout" rel="external">{move || t(&lang().get(), "sign_out")}</a>
</nav>
}
.into_any(),
None => view! {
<nav class="session">
<a href=format!("/auth/login?redirect={redirect}") rel="external">{move || t(&lang().get(), "sign_in")}</a>
</nav>
}
.into_any(),
}
});
let has_slides = slideshow.is_some();
let mark = move || view! {
<a class="wordmark" class:wordmark-house=house_wordmark href="/">
<img src=wordmark.clone() alt=site_title.clone() style=wordmark_style.clone()/>
</a>
};
view! {
<header class="hero" class:hero-module=has_module>
<header class="hero" class:hero-module=has_module class:hero-slides=has_slides>
<Announcements current_id=current_id/>
{session}
{has_module
.then(|| {
view! {
@@ -1004,10 +988,13 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur
<script type="module" inner_html=early_script.unwrap_or_default()></script>
}
})}
// With pictures, the wordmark is a bar of its own above
// them and the question comes under; without, the wordmark
// heads the copy as it always has.
{has_slides.then(|| view! { <div class="hero-bar">{mark()}</div> })}
{slideshow.map(|show| view! { <SlideStrip show=show/> })}
<div class="hero-copy">
<a class="wordmark" class:wordmark-house=house_wordmark href="/">
<img src=wordmark alt=site_title style=wordmark_style/>
</a>
{(!has_slides).then(|| mark())}
<h1>{title}</h1>
<p inner_html=render_inline_markdown(&description)></p>
</div>
@@ -1015,6 +1002,59 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur
}
}
/// Pictures across the page (`slideshow`): the full width of the
/// screen, each picture as wide as its own shape at the strip's height,
/// the caption laid over its foot. Inert until the Swiper bundle
/// upgrades the elements; without it, a row that scrolls sideways.
#[component]
fn SlideStrip(show: crate::content::Slideshow) -> impl IntoView {
let height = format!("--slideshow-height: {}svh", show.max_height);
view! {
// In the page itself, so the strip works from the
// server's HTML alone; the head's copy above covers
// arriving here by a link within the site.
<script src="/swiper-element-bundle.min.js" defer=true></script>
<swiper-container
class="slideshow"
style=height
slides-per-view="auto"
space-between="6"
grab-cursor="true"
free-mode="true"
mousewheel-force-to-axis="true"
keyboard="true"
navigation="true"
>
<For
each={move || show.slides.clone().into_iter().enumerate().collect::<Vec<_>>()}
key=|(_, slide)| slide.image.clone()
children=|(n, slide): (usize, crate::content::Slide)| {
let caption = slide.caption.clone();
// The first screenful loads with the
// page; the rest when swiped to.
let loading = if n < 4 { "eager" } else { "lazy" };
view! {
<swiper-slide>
<figure class="slide">
<img
src=slide.src()
alt=slide.caption.clone()
width=slide.width.map(|w| w.to_string())
height=slide.height.map(|h| h.to_string())
loading=loading
decoding="async"
/>
{(!caption.trim().is_empty()).then(|| view! { <figcaption>{caption}</figcaption> })}
</figure>
</swiper-slide>
}
.into_any()
}
/>
</swiper-container>
}
}
/// An alternative's banner imagery: nothing, a plain image, or - for
/// several urls - a Swiper Element card deck. The `swiper-container`/
/// `swiper-slide` custom elements render inert server-side and upgrade
@@ -2461,6 +2501,7 @@ pub async fn get_site() -> Result<SiteConfig, ServerFnError> {
// own configuration, and the browser has to know before it decides
// whether to draw a map or ask for two numbers.
site.maps = crate::maps::enabled();
site.sign_in = state.oidc.enabled();
Ok(site)
}
+6 -1
View File
@@ -779,8 +779,13 @@ pub struct SiteConfig {
/// the way to the browser (see `get_site`), never from content, so
/// a `type: location` field knows whether to offer a map or two
/// number inputs.
#[serde(skip)]
#[serde(default)]
pub maps: bool,
/// Whether this instance has sign-in at all (a provider is
/// configured): set on the way to the browser like `maps`, so the
/// header knows whether to offer it.
#[serde(default)]
pub sign_in: bool,
/// Who this site's mail comes from. Absent means the site sends
/// nothing: a state's `mail:` and the invite mail are logged and
/// skipped, never half-sent (see `mail.rs`).
+3
View File
@@ -22,6 +22,7 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
("en", "if_stuck") => " if you get stuck.",
("en", "send") => "Send",
("en", "sign_in") => "Sign in",
("en", "sign_out") => "Sign out",
("en", "nothing_here_yet") => "Nothing here yet.",
("en", "nothing_here") => "Nothing here",
("en", "back_to_start") => "back to the start",
@@ -51,6 +52,7 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
("no", "if_stuck") => " om du står fast.",
("no", "send") => "Send",
("no", "sign_in") => "Logg inn",
("no", "sign_out") => "Logg ut",
("no", "nothing_here_yet") => "Ingenting her ennå.",
("no", "nothing_here") => "Ingenting her",
("no", "back_to_start") => "tilbake til start",
@@ -98,6 +100,7 @@ mod tests {
"if_stuck",
"send",
"sign_in",
"sign_out",
"nothing_here_yet",
"nothing_here",
"back_to_start",
+59 -8
View File
@@ -39,7 +39,7 @@ pub async fn get_resource(
.as_ref()
.ok_or_else(|| ServerFnError::new("feature has no resource"))?;
authorize_resource(&state, &question_id, &alternative, &feature_name).await?;
fetch_resource_value(&state, resource, &params).await
fetch_resource_value(&state, resource, &as_the_person_asking(params).await).await
}
/// The `Requirement.resource`-backed counterpart to `get_resource` -
@@ -83,7 +83,7 @@ pub async fn get_requirement_options(
.as_ref()
.ok_or_else(|| ServerFnError::new("requirement has no resource"))?;
authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}")).await?;
fetch_resource_value(&state, resource, &params).await
fetch_resource_value(&state, resource, &as_the_person_asking(params).await).await
}
/// The `Requirement.bind`-backed counterpart to
@@ -114,7 +114,7 @@ pub async fn get_requirement_binding(
.as_ref()
.ok_or_else(|| ServerFnError::new("requirement has no binding"))?;
authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}|bind")).await?;
fetch_resource_value(&state, &bind.resource, &params).await
fetch_resource_value(&state, &bind.resource, &as_the_person_asking(params).await).await
}
/// Scoped to the named alternative first, not flattened across all of
@@ -211,8 +211,12 @@ fn shows_state(resource: &crate::content::ResourceSpec, value: &serde_json::Valu
/// stamped on it. A missing field never matches: "everything that did
/// not say" is not something a page should be able to ask for by
/// accident.
///
/// `equals: "{user.email}"` is the signed-in person's own address: a
/// list of "my cases". It is filled from the session, never from the
/// caller, and with nobody signed in it matches nothing.
#[cfg(feature = "ssr")]
fn matches_filter(
pub(crate) fn matches_filter(
resource: &crate::content::ResourceSpec,
value: &serde_json::Value,
params: &std::collections::HashMap<String, String>,
@@ -220,19 +224,54 @@ fn matches_filter(
let Some(filter) = &resource.filter else {
return true;
};
let wanted = crate::content::template_action(&filter.equals, &serde_json::json!(params))
.unwrap_or_else(|| filter.equals.clone());
let equals = if filter.equals.contains("{user.email}") {
match params.get(USER_EMAIL).filter(|e| !e.is_empty()) {
Some(email) => filter.equals.replace("{user.email}", email),
None => return false,
}
} else {
filter.equals.clone()
};
let wanted = crate::content::template_action(&equals, &serde_json::json!(params)).unwrap_or(equals);
// Addresses compare without case; everything else as written.
let same = |found: &str| if filter.equals.contains("{user.email}") { found.trim().eq_ignore_ascii_case(&wanted) } else { found == wanted };
let found = value
.get("responses")
.and_then(|r| r.get(&filter.field))
.or_else(|| value.get(&filter.field));
match found {
Some(serde_json::Value::String(s)) => s == &wanted,
Some(other) => other.to_string() == wanted,
Some(serde_json::Value::String(s)) => same(s),
Some(other) => same(&other.to_string()),
None => false,
}
}
/// The parameter a `where` reads the signed-in person's address from.
/// Set by the server from the session; a caller's own is thrown away.
#[cfg(feature = "ssr")]
pub(crate) const USER_EMAIL: &str = "__user_email";
/// The caller's parameters with the session's person put in and
/// anything pretending to be them taken out.
#[cfg(feature = "ssr")]
pub(crate) fn with_person(mut params: std::collections::HashMap<String, String>, user: Option<&crate::auth::User>) -> std::collections::HashMap<String, String> {
params.retain(|k, _| !k.starts_with("__"));
if let Some(email) = user.and_then(|u| u.email.as_deref()).filter(|e| !e.trim().is_empty()) {
params.insert(USER_EMAIL.to_string(), email.trim().to_lowercase());
}
params
}
#[cfg(feature = "ssr")]
async fn as_the_person_asking(params: std::collections::HashMap<String, String>) -> std::collections::HashMap<String, String> {
use crate::auth::{User, SESSION_USER_KEY};
let user = match leptos_axum::extract::<tower_sessions::Session>().await {
Ok(session) => session.get::<User>(SESSION_USER_KEY).await.ok().flatten(),
Err(_) => None,
};
with_person(params, user.as_ref())
}
#[cfg(feature = "ssr")]
async fn fetch_resource_value(
state: &crate::server::AppState,
@@ -559,6 +598,18 @@ mod tests {
assert!(!matches_filter(&children, &theirs, &params));
// A record that did not say is not everyone's child.
assert!(!matches_filter(&children, &neither, &params));
// "My cases": the session's address, never the caller's.
let own = spec("source: { kind: kv, bucket: cases }\nrequires_group: customers\nwhere: { field: email, equals: \"{user.email}\" }\n");
let hers = serde_json::json!({ "responses": { "email": "Kari@Vel.no" } });
let his = serde_json::json!({ "responses": { "email": "ola@vel.no" } });
let kari = crate::auth::User { sub: "s".into(), username: "kari".into(), display_name: "Kari".into(), groups: vec![], phone: None, email: Some("kari@vel.no".into()) };
let forged: std::collections::HashMap<String, String> = [(USER_EMAIL.to_string(), "ola@vel.no".to_string())].into_iter().collect();
let asked = with_person(forged.clone(), Some(&kari));
assert!(matches_filter(&own, &hers, &asked) && !matches_filter(&own, &his, &asked));
// Nobody signed in: the forged parameter is dropped, and nothing matches.
let anonymous = with_person(forged, None);
assert!(!matches_filter(&own, &hers, &anonymous) && !matches_filter(&own, &his, &anonymous));
// No filter, no narrowing: every resource written before this
// existed still lists its whole bucket.
let plain = spec("source: { kind: kv, bucket: objections }\npublic: true\n");
+5
View File
@@ -50,6 +50,11 @@ const NONCE_KEY: &str = "oidc_nonce";
const REDIRECT_KEY: &str = "oidc_post_login_redirect";
impl Oidc {
/// Whether a provider is configured.
pub fn enabled(&self) -> bool {
self.inner.is_some()
}
/// Sign-in disabled: what a content-only instance has, and what a
/// test builds an `AppState` with.
pub fn disabled() -> Self {
+11 -6
View File
@@ -277,13 +277,15 @@ pub async fn attachment(
// cannot be used to learn which buckets, records or fields exist.
let missing = || (StatusCode::NOT_FOUND, "not found".to_string());
if !may_read_bucket(&state, &session, &bucket).await {
return Err(missing());
}
let store = state.jetstream.get_key_value(&bucket).await.map_err(|_| missing())?;
let bytes = store.get(&record).await.map_err(|_| missing())?.ok_or_else(missing)?;
let answer: crate::answers::Answer = serde_json::from_slice(&bytes).map_err(|_| missing())?;
let as_listed: serde_json::Value = serde_json::from_slice(&bytes).map_err(|_| missing())?;
// Through a list the caller may read, and one that shows this
// record: a list of "my cases" opens my files and nobody else's.
if !may_read_record(&state, &session, &bucket, &as_listed).await {
return Err(missing());
}
let answer: crate::answers::Answer = serde_json::from_value(as_listed).map_err(|_| missing())?;
let key = answer
.responses
.get(&field)
@@ -331,20 +333,23 @@ pub async fn attachment(
/// Whether this caller may see this bucket at all: whether any feature
/// in the loaded content lists it and the policy lets them read that
/// feature. The desk that shows the record is what grants the file.
async fn may_read_bucket(
async fn may_read_record(
state: &AppState,
session: &tower_sessions::Session,
bucket: &str,
record: &serde_json::Value,
) -> bool {
use crate::auth::{User, SESSION_USER_KEY};
let user = session.get::<User>(SESSION_USER_KEY).await.ok().flatten();
let principal = crate::access::Principal::from_user(user.as_ref());
let params = crate::resource::with_person(Default::default(), user.as_ref());
let access = state.access.load();
state.questions.load().values().any(|question| {
question.alternatives.iter().any(|alternative| {
alternative.features.iter().any(|feature| {
feature.resource.as_ref().and_then(|r| r.bucket()) == Some(bucket)
&& feature.resource.as_ref().is_some_and(|r| crate::resource::matches_filter(r, record, &params))
&& access.allows(
&principal,
crate::access::Action::Read,