diff --git a/CHANGELOG.md b/CHANGELOG.md index ca38cf6..05d386a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ One line per change, grouped by the release that shipped it. Newest first. A release is a Cargo.toml bump plus a `v` tag; the content repos pin the tag they run (see the end of this file). +## Unreleased + +- The header knows who is here: on a site with sign-in, a link top right to sign in, or the person's name and the way out. A page with a `slideshow` gets the wordmark as a bar of its own, the strip full width under it, and the question after. +- "My cases": `where: { field: email, equals: "{user.email}" }` lists the records that carry the signed-in person's own address, filled from the session and never from the caller; nobody signed in matches nothing. An attachment is fetched only through a list that shows its record, so a list of my cases opens my files and nobody else's. +- Form controls scale with the text around them (em, not rem): fields, pills, buttons and labels follow a site's body size. The date picker's calendar icon is the size of the text beside it, and the file picker's button takes the field's colours instead of the browser's. +- Fixed: `maps` never reached the browser (`serde(skip)`), so a page with a map drew it on the server and dropped it on hydration. + ## 0.6.1 (2026-09-30) - Trainer: `documents` in a plan. A text the site carries word for word (bylaws, terms) becomes a page of its own where every section is one alternative: its heading the card's, its paragraphs the card's words, from a markdown file beside the plan. No model touches it. diff --git a/docs/content-reference.md b/docs/content-reference.md index 5540bec..c1b4f3e 100644 --- a/docs/content-reference.md +++ b/docs/content-reference.md @@ -263,6 +263,10 @@ A spec with neither `public: true` nor `requires_group` is unreachable: reads fail closed, and mutations always need a group whatever `public` says. +`where: { field: email, equals: "{user.email}" }` is "my cases": the +records carrying the signed-in person's own address, read from the +session. A file on such a record is theirs alone to open. + `states` and `where` narrow a listing. They matter most for a register that is public by law — public about what was decided, not about what was withdrawn — while the desk working the same bucket is a different diff --git a/src/app.rs b/src/app.rs index 8155fea..41496ae 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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. - - - >()} - 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! { - -
- slide.caption.clone() - {(!caption.trim().is_empty()).then(|| view! {
{caption}
})} -
-
- } - .into_any() - } - /> -
- } - })} // 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, + #[prop(optional_no_strip)] slideshow: Option, +) -> 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! { + + } + .into_any(), + None => view! { + + } + .into_any(), + } + }); + let has_slides = slideshow.is_some(); + let mark = move || view! { + + site_title.clone() + + }; + view! { -
+
+ {session} {has_module .then(|| { view! { @@ -1004,10 +988,13 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur } })} + // 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! {
{mark()}
})} + {slideshow.map(|show| view! { })}
- - site_title - + {(!has_slides).then(|| mark())}

{title}

@@ -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. + + + >()} + 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! { + +
+ slide.caption.clone() + {(!caption.trim().is_empty()).then(|| view! {
{caption}
})} +
+
+ } + .into_any() + } + /> +
+ } +} + /// 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 { // 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) } diff --git a/src/content/mod.rs b/src/content/mod.rs index 8d53346..e3f70d4 100644 --- a/src/content/mod.rs +++ b/src/content/mod.rs @@ -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`). diff --git a/src/i18n.rs b/src/i18n.rs index d74b885..23af324 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -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", diff --git a/src/resource.rs b/src/resource.rs index 43dadd0..6dbb7df 100644 --- a/src/resource.rs +++ b/src/resource.rs @@ -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, ¶ms).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, ¶ms).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, ¶ms).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, @@ -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, user: Option<&crate::auth::User>) -> std::collections::HashMap { + 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) -> std::collections::HashMap { + use crate::auth::{User, SESSION_USER_KEY}; + let user = match leptos_axum::extract::().await { + Ok(session) => session.get::(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, ¶ms)); // A record that did not say is not everyone's child. assert!(!matches_filter(&children, &neither, ¶ms)); + + // "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 = [(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"); diff --git a/src/server/oidc.rs b/src/server/oidc.rs index 7908bfe..9b783e2 100644 --- a/src/server/oidc.rs +++ b/src/server/oidc.rs @@ -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 { diff --git a/src/upload.rs b/src/upload.rs index 411b572..da98169 100644 --- a/src/upload.rs +++ b/src/upload.rs @@ -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::(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, ¶ms)) && access.allows( &principal, crate::access::Action::Read, diff --git a/style/main.css b/style/main.css index 61dd9f4..7ec055e 100644 --- a/style/main.css +++ b/style/main.css @@ -173,6 +173,61 @@ main.not-found { gap: 1rem; } +/* Who is here and the way in or out: top right of every page, on a + site that has sign-in. */ +.session { + position: absolute; + top: 1rem; + right: 1.5rem; + z-index: 5; + display: flex; + gap: 0.9rem; + align-items: baseline; + font-size: 0.88rem; +} + +.session a { + color: var(--ink); + text-decoration: none; + border-bottom: 0.06rem solid var(--line); +} + +.session a:hover { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.session-name { + color: var(--ink-dim); +} + +/* A page with pictures: the wordmark is a bar of its own, the strip + runs the full width under it, and the question follows. */ +.hero-slides { + max-width: none; + padding: 0 0 2rem; + align-items: stretch; + gap: 0; +} + +.hero-bar { + display: flex; + align-items: center; + padding: 0.9rem 1.5rem; +} + +.hero-slides .slideshow { + margin-bottom: 2rem; +} + +.hero-slides .hero-copy { + width: 100%; + max-width: 46rem; + margin: 0 auto; + padding: 0 1.5rem; + box-sizing: border-box; +} + .wordmark { display: inline-flex; opacity: 0.85; @@ -742,7 +797,7 @@ main.not-found { .feature p { color: var(--ink-dim); - font-size: 0.95rem; + font-size: 0.95em; } .field { @@ -753,7 +808,7 @@ main.not-found { } .field-label { - font-size: 0.85rem; + font-size: 0.85em; font-weight: 600; color: var(--ink-dim); } @@ -763,17 +818,74 @@ main.not-found { margin-left: 0.2em; } +/* Controls take their size from the text around them (em, not rem): + a site that sets its body larger, or a card that sets itself + smaller, gets fields, pills and buttons to match, and everything + inside a control - the date picker's icon, the file picker's button + - scales with the control. */ input, textarea { font: inherit; color: var(--ink); background: var(--paper); border: 0.06rem solid var(--line); - border-radius: 0.6rem; - padding: 0.65rem 0.8rem; + border-radius: 0.6em; + padding: 0.65em 0.8em; width: 100%; } +/* The calendar icon at the size of the text it sits beside, and a + target worth aiming at. */ +input[type="date"], +input[type="time"], +input[type="datetime-local"] { + min-height: 2.9em; +} + +input[type="date"]::-webkit-calendar-picker-indicator, +input[type="time"]::-webkit-calendar-picker-indicator, +input[type="datetime-local"]::-webkit-calendar-picker-indicator { + width: 1.35em; + height: 1.35em; + padding: 0.2em; + margin-left: 0.4em; + cursor: pointer; + opacity: 0.75; +} + +input[type="date"]::-webkit-calendar-picker-indicator:hover, +input[type="time"]::-webkit-calendar-picker-indicator:hover, +input[type="datetime-local"]::-webkit-calendar-picker-indicator:hover { + opacity: 1; +} + +/* The file picker's own button, in the field's colours rather than + the browser's: it follows whatever ink the field has, so it holds + on a light field and a dark one alike. */ +input[type="file"] { + padding: 0.45em; + cursor: pointer; +} + +input[type="file"]::file-selector-button { + font: inherit; + font-size: 0.85em; + font-weight: 600; + color: inherit; + background: color-mix(in srgb, currentColor 9%, transparent); + border: 0.06rem solid color-mix(in srgb, currentColor 28%, transparent); + border-radius: 999rem; + padding: 0.4em 1em; + margin-right: 0.8em; + cursor: pointer; + transition: border-color 120ms, background 120ms; +} + +input[type="file"]::file-selector-button:hover { + background: var(--accent-soft); + border-color: var(--accent); +} + textarea { min-height: 5rem; resize: vertical; @@ -1034,14 +1146,14 @@ textarea:focus { } .alt-submit { - font-family: var(--sans); + font-family: inherit; font-weight: 600; - font-size: 0.95rem; + font-size: 0.95em; color: var(--paper); background: var(--accent); border: none; - border-radius: 0.7rem; - padding: 0.75rem 1.4rem; + border-radius: 0.7em; + padding: 0.75em 1.4em; cursor: pointer; transition: filter 120ms, transform 120ms; /* A gateway alternative renders this as an (navigation, no POST); @@ -1247,13 +1359,13 @@ textarea:focus { } .select-option { - font-family: var(--sans); - font-size: 0.85rem; + font-family: inherit; + font-size: 0.85em; color: var(--ink); background: var(--paper); border: 0.06rem solid var(--line); border-radius: 999rem; - padding: 0.4rem 0.9rem; + padding: 0.45em 1em; cursor: pointer; transition: border-color 120ms, color 120ms, background 120ms; }