From 6b72ae568a929ea3227390997db7e67300422ad9 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Wed, 30 Sep 2026 17:32:41 +0200 Subject: [PATCH 1/4] The header knows who is here; my cases; controls that scale with the text 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 Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt --- CHANGELOG.md | 7 ++ docs/content-reference.md | 4 + src/app.rs | 159 ++++++++++++++++++++++++-------------- src/content/mod.rs | 7 +- src/i18n.rs | 3 + src/resource.rs | 67 ++++++++++++++-- src/server/oidc.rs | 5 ++ src/upload.rs | 17 ++-- style/main.css | 134 +++++++++++++++++++++++++++++--- 9 files changed, 318 insertions(+), 85 deletions(-) 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; } -- 2.54.0 From f2b131ebbbc0823ba6a7c1a7241328bd31f16bc2 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Wed, 30 Sep 2026 17:34:21 +0200 Subject: [PATCH 2/4] KANIDM_GROUP_PREFIX: plain group names in content on a shared Kanidm Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt --- CHANGELOG.md | 1 + docs/operations.md | 1 + src/people.rs | 15 +++++++++++---- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05d386a..38da7d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ content repos pin the tag they run (see the end of this file). - 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. +- `KANIDM_GROUP_PREFIX`: a site on a Kanidm it shares with others keeps plain group names in its content; the prefix is put on when portal adds or removes a member, and the client's claim map takes it off. - 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) diff --git a/docs/operations.md b/docs/operations.md index 3b39dd1..e04ee72 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -71,6 +71,7 @@ values. | `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET` | sign-in | | `PEOPLE_BACKEND` | `kanidm` or `memberships` (see [Memberships](memberships.md)); unset, Kanidm when `KANIDM_API_TOKEN` is set, memberships otherwise | | `KANIDM_API_TOKEN` | invites and `grants:` through Kanidm | +| `KANIDM_GROUP_PREFIX` | for a Kanidm shared with other sites: the content's `kunder` is the group `kunder` there, and the OAuth2 client's claim map gives it back to portal as `kunder` | | `PHONE_COUNTRY` | the calling code a bare phone number gets, `+47` unless set | | `OIDC_EXTRA_SCOPES` | scopes to ask the provider for besides `openid profile email`: `phoneNumber` for Vipps. Not for Kanidm, which denies a sign-in that asks for a scope the person does not hold | | `SEED_ADMIN_EMAIL` or `SEED_ADMIN_PHONE`, `SEED_ADMIN_NAME`, `SEED_GROUP` | the first person in, invited once at start into the most privileged group (or `SEED_GROUP`) | diff --git a/src/people.rs b/src/people.rs index e318b66..db8db9c 100644 --- a/src/people.rs +++ b/src/people.rs @@ -184,6 +184,12 @@ fn name_from_search(body: &str) -> Option { pub struct Kanidm { base: String, token: String, + /// What this site's groups are called in a Kanidm it shares with + /// other sites: `KANIDM_GROUP_PREFIX=klingenberg_` makes the + /// content's `kunder` the group `klingenberg_kunder` there. The + /// OAuth2 client's claim map takes the prefix off again, so content + /// and policy never see it. Empty for a Kanidm of the site's own. + group_prefix: String, } impl Kanidm { @@ -192,7 +198,8 @@ impl Kanidm { pub fn from_env() -> Option { let base = std::env::var("KANIDM_URL").ok()?.trim_end_matches('/').to_string(); let token = std::env::var("KANIDM_API_TOKEN").ok().filter(|t| !t.is_empty())?; - Some(Kanidm { base, token }) + let group_prefix = std::env::var("KANIDM_GROUP_PREFIX").unwrap_or_default().trim().to_string(); + Some(Kanidm { base, token, group_prefix }) } async fn call(&self, method: openidconnect::reqwest::Method, path: &str, body: Option) -> anyhow::Result<(u16, String)> { @@ -262,7 +269,7 @@ impl Kanidm { /// Who is in a group, by account name. pub async fn members(&self, group: &str) -> anyhow::Result> { - let (status, text) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/group/{group}/_attr/member"), None).await?; + let (status, text) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/group/{}{group}/_attr/member", self.group_prefix), None).await?; if !(200..300).contains(&status) { anyhow::bail!("kanidm: reading the members of {group}: http {status}: {text}"); } @@ -273,7 +280,7 @@ impl Kanidm { /// service account manages, like adding them was. pub async fn remove_member(&self, group: &str, username: &str) -> anyhow::Result<()> { let (status, text) = self - .call(openidconnect::reqwest::Method::DELETE, &format!("/v1/group/{group}/_attr/member"), Some(serde_json::json!([username]))) + .call(openidconnect::reqwest::Method::DELETE, &format!("/v1/group/{}{group}/_attr/member", self.group_prefix), Some(serde_json::json!([username]))) .await?; if !(200..300).contains(&status) { anyhow::bail!("kanidm: removing {username} from {group}: http {status}: {text}"); @@ -340,7 +347,7 @@ impl Kanidm { // tidying up after itself. already_member = !created && self.members(group).await.is_ok_and(|m| m.contains(&username)); let (status, text) = self - .call(openidconnect::reqwest::Method::POST, &format!("/v1/group/{group}/_attr/member"), Some(serde_json::json!([username]))) + .call(openidconnect::reqwest::Method::POST, &format!("/v1/group/{}{group}/_attr/member", self.group_prefix), Some(serde_json::json!([username]))) .await?; if !(200..300).contains(&status) { anyhow::bail!("kanidm: adding {username} to {group}: http {status}: {text}"); -- 2.54.0 From a822f2175f376be864a2ad81e1bccff042cf2918 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Wed, 30 Sep 2026 17:36:08 +0200 Subject: [PATCH 3/4] Trainer: followed_by - a group whose members each see their own cases; file fields Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt --- src/trainer/build.rs | 63 ++++++++++++++++++++++++++++++++++++++++---- src/trainer/plan.rs | 6 +++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/trainer/build.rs b/src/trainer/build.rs index 40a1b41..41663fb 100644 --- a/src/trainer/build.rs +++ b/src/trainer/build.rs @@ -343,6 +343,14 @@ pub struct PlanNeed { /// Groups that see these cases on their desk and move nothing. #[serde(default)] pub watched_by: Vec, + /// Groups whose members each see *their own* cases and nobody + /// else's: the ones that carry the address they are signed in + /// with. A customer follows their assignment; a member sees what + /// they sent. The form must collect a required `email`, and the + /// group is usually one a stage `grants`, so the person who sent + /// the case is who ends up able to follow it. + #[serde(default)] + pub followed_by: Vec, /// The event name a stage is entered by, where it must not be the /// builder's default (the stage's own name; `received` for the /// first). A live site replays its records by event name, so a @@ -432,7 +440,7 @@ impl Field { /// What a public listing may show: everything but contact details /// and what the plan marks private. pub fn is_public(&self) -> bool { - !self.private && self.kind != "email" && self.kind != "tel" + !self.private && self.kind != "email" && self.kind != "tel" && self.kind != "file" } } @@ -474,7 +482,7 @@ impl Plan { } } -const FIELD_TYPES: &[&str] = &["text", "email", "tel", "textarea", "date", "select", "location"]; +const FIELD_TYPES: &[&str] = &["text", "email", "tel", "textarea", "date", "select", "location", "file"]; fn is_id(s: &str) -> bool { !s.is_empty() && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') @@ -687,6 +695,14 @@ impl Plan { bail!(at("a stage that grants a group makes an account for the person, so the form must collect a required `name` (text) and a required `email` (type email)")); } } + for group in &need.followed_by { + if !is_id(group) || group == PUBLIC || group == SUBMITTER { + bail!(at(&format!("followed_by must name groups, not {group:?}"))); + } + if !need.collects.iter().any(|f| f.name == "email" && f.kind == "email" && !f.optional) { + bail!(at("followed_by shows a person the cases that carry their address, so the form must collect a required `email` of type email")); + } + } for (stage, e) in &need.escalate { if e.days == 0 { bail!(at(&format!("escalate from {stage:?} must wait at least one day"))); @@ -1184,7 +1200,9 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap { // The list itself says what a row is and what the // buttons under it do; the card's own description says // what the cases are. - let note = if moves.is_empty() { + let note = if follows_own(needs[0], &group) { + loc(lang, "Each row is one of your own cases and the stage it has reached.", "Hver rad er én av dine egne saker og hvor langt den har kommet.") + } else if moves.is_empty() { loc(lang, "Each row is one case and the stage it has reached. Another desk moves these.", "Hver rad er én sak og hvor langt den har kommet. En annen pult flytter disse.") } else { loc(lang, "Each row is one case. The buttons record what you have just done with it.", "Hver rad er én sak. Knappene fører opp det du nettopp har gjort med den.") @@ -1480,7 +1498,16 @@ fn escalated_states_for(need: &PlanNeed, group: &str) -> Vec { /// A group's desk shows a bucket when it moves something in it, is /// answerable for it, or watches it. fn on_desk(need: &PlanNeed, group: &str) -> bool { - group != PUBLIC && (need.handled_by == group || need.watched_by.iter().any(|g| g == group) || !moves_of(need, group).is_empty()) + group != PUBLIC && (need.handled_by == group || need.watched_by.iter().any(|g| g == group) || need.followed_by.iter().any(|g| g == group) || !moves_of(need, group).is_empty()) +} + +/// A group that has the bucket only to follow its own cases: its list +/// is narrowed to the signed-in person's address. +fn follows_own(need: &PlanNeed, group: &str) -> bool { + need.followed_by.iter().any(|g| g == group) + && need.handled_by != group + && !need.watched_by.iter().any(|g| g == group) + && !need.moves_by.values().flatten().any(|m| m == group) } fn groups(plan: &Plan) -> BTreeSet { @@ -1493,6 +1520,7 @@ fn groups(plan: &Plan) -> BTreeSet { .chain(n.watched_by.iter().filter(|g| *g != PUBLIC).cloned()) .chain(n.moves_by.values().flatten().filter(|m| *m != SUBMITTER).cloned()) .chain(n.escalate.values().map(|e| e.to.clone())) + .chain(n.followed_by.iter().cloned()) }) .collect() } @@ -2154,6 +2182,10 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result Date: Wed, 30 Sep 2026 17:38:32 +0200 Subject: [PATCH 4/4] A stored file on a record shows as its name and opens from the row Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt --- CHANGELOG.md | 1 + src/app.rs | 38 ++++++++++++++++++++++++++++++++++++++ style/main.css | 7 +++++++ 3 files changed, 46 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38da7d1..8771de9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ content repos pin the tag they run (see the end of this file). - 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. +- A stored file on a listed record shows as the name it was sent with and opens from the row, for whoever the list shows the record to. It used to show as its storage key, a string nobody could open. - 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. - `KANIDM_GROUP_PREFIX`: a site on a Kanidm it shares with others keeps plain group names in its content; the prefix is put on when portal adds or removes a member, and the client's claim map takes it off. - Fixed: `maps` never reached the browser (`serde(skip)`), so a page with a map drew it on the server and dropped it on hydration. diff --git a/src/app.rs b/src/app.rs index 41496ae..daad244 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1400,6 +1400,7 @@ fn AlternativeCard( feature_name=feature_name.clone() transitions=spec.transitions.clone() link=spec.link.clone() + bucket=spec.bucket().map(str::to_string) empty=spec.empty.clone() pending_transitions=pending_transitions transition_batch=transition_batch @@ -1853,6 +1854,7 @@ fn ResourceFeature( feature_name: String, transitions: Vec, link: Option, + bucket: Option, empty: Option, pending_transitions: RwSignal>, transition_batch: ServerAction, @@ -1888,6 +1890,7 @@ fn ResourceFeature( let feature_name = feature_name.clone(); let transitions = transitions.clone(); let link = link.clone(); + let bucket = bucket.clone(); let empty = empty.clone().unwrap_or_else(|| t(&lang().get(), "nothing_here_yet").to_string()); data.get() .map(|res| match res { @@ -1897,6 +1900,7 @@ fn ResourceFeature( feature_name=feature_name transitions=transitions link=link + bucket=bucket empty=empty value=value pending_transitions=pending_transitions @@ -1925,6 +1929,7 @@ fn ResourceValue( feature_name: String, transitions: Vec, link: Option, + bucket: Option, empty: String, value: serde_json::Value, pending_transitions: RwSignal>, @@ -1957,12 +1962,14 @@ fn ResourceValue( let feature_name = feature_name.clone(); let transitions = transitions.clone(); let link = link.clone(); + let bucket = bucket.clone(); move |answer: Answer| { view! { Option<&str> { + let (id, rest) = value.split_at_checked(36)?; + let shaped = id.char_indices().all(|(i, c)| if matches!(i, 8 | 13 | 18 | 23) { c == '-' } else { c.is_ascii_hexdigit() }); + rest.strip_prefix('-').filter(|name| shaped && !name.is_empty()) +} + #[component] fn AnswerRow( feature_name: String, transitions: Vec, link: Option, + bucket: Option, answer: Answer, pending_transitions: RwSignal>, transition_batch: ServerAction, @@ -2255,6 +2271,9 @@ fn AnswerRow( let item_id = answer.id.clone(); let key = (feature_name.clone(), item_id.clone()); let link = link.map(|l| (l.href(&answer.id, &answer.responses), l.label)); + // Where a stored file on this record is fetched from: the record + // names it, never the key (see `upload::attachment`). + let attachment_base = bucket.map(|b| format!("/attachment/{b}/{}", answer.id)); view! {
@@ -2288,6 +2307,17 @@ fn AnswerRow( .into_any(); } let text = value.as_str().map(str::to_string).unwrap_or_else(|| value.to_string()); + // A stored file shows as its name, and opens. + if let (Some(base), Some(filename)) = (attachment_base.clone(), uploaded_filename(&text)) { + let href = format!("{base}/{key}"); + return view! { + + } + .into_any(); + } view! {
{key} @@ -3028,6 +3058,14 @@ mod responsible_tests { Responsible { name: "R".into(), contact: contact.into(), group: group.map(String::from) } } + #[test] + fn an_uploads_key_shows_as_the_name_it_was_sent_with() { + assert_eq!(super::uploaded_filename("3f2b8c1e-9a4d-4e7b-8c21-0a1b2c3d4e5f-rapport 2026.pdf"), Some("rapport 2026.pdf")); + assert_eq!(super::uploaded_filename("rapport.pdf"), None); + assert_eq!(super::uploaded_filename("3f2b8c1e-9a4d-4e7b-8c21-0a1b2c3d4e5f-"), None); + assert_eq!(super::uploaded_filename("Storgata 1, 1850 Mysen - se vedlegg i e-post fra oss"), None); + } + #[test] fn only_the_person_a_page_names_may_suggest_a_change_to_it() { assert!(is_responsible(Some(&user(Some("kari@vel.no"), &[])), &named("mailto:Kari@Vel.no", None))); diff --git a/style/main.css b/style/main.css index 7ec055e..852fa84 100644 --- a/style/main.css +++ b/style/main.css @@ -1269,6 +1269,13 @@ textarea:focus { color: var(--accent); } +/* A stored file on a record: its name, and it opens. */ +.answer-file { + color: var(--accent); + text-decoration: none; + border-bottom: 0.06rem solid var(--accent); +} + /* The page about a listed record, when the list offers one. */ .answer-link { display: inline-block; -- 2.54.0