The header knows who is here; my cases; controls that scale with the text
Test / test (pull_request) Successful in 1m12s
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:
co-authored by
Claude Opus 5.5
parent
9a96386444
commit
6b72ae568a
@@ -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<version>` tag; the
|
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
|
||||||
content repos pin the tag they run (see the end of this file).
|
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)
|
## 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.
|
- 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.
|
||||||
|
|||||||
@@ -263,6 +263,10 @@ A spec with neither `public: true` nor `requires_group` is
|
|||||||
unreachable: reads fail closed, and mutations always need a group
|
unreachable: reads fail closed, and mutations always need a group
|
||||||
whatever `public` says.
|
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
|
`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
|
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
|
was withdrawn — while the desk working the same bucket is a different
|
||||||
|
|||||||
+100
-59
@@ -396,61 +396,9 @@ fn QuestionView(
|
|||||||
landing=question_id == "/"
|
landing=question_id == "/"
|
||||||
site=site.clone()
|
site=site.clone()
|
||||||
current_id=question_id.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
|
// A page that is about somewhere says where, under the
|
||||||
// question: a still from this origin, and the place in words
|
// question: a still from this origin, and the place in words
|
||||||
// for anyone who cannot see it.
|
// for anyone who cannot see it.
|
||||||
@@ -895,7 +843,15 @@ mod gesture {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[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
|
// 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.
|
// actually "the" hero for; every other page gets the plain header.
|
||||||
// WHAT the piece is belongs to the content repo: site.yaml's
|
// 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! {
|
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/>
|
<Announcements current_id=current_id/>
|
||||||
|
{session}
|
||||||
{has_module
|
{has_module
|
||||||
.then(|| {
|
.then(|| {
|
||||||
view! {
|
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>
|
<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">
|
<div class="hero-copy">
|
||||||
<a class="wordmark" class:wordmark-house=house_wordmark href="/">
|
{(!has_slides).then(|| mark())}
|
||||||
<img src=wordmark alt=site_title style=wordmark_style/>
|
|
||||||
</a>
|
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
<p inner_html=render_inline_markdown(&description)></p>
|
<p inner_html=render_inline_markdown(&description)></p>
|
||||||
</div>
|
</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
|
/// An alternative's banner imagery: nothing, a plain image, or - for
|
||||||
/// several urls - a Swiper Element card deck. The `swiper-container`/
|
/// several urls - a Swiper Element card deck. The `swiper-container`/
|
||||||
/// `swiper-slide` custom elements render inert server-side and upgrade
|
/// `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
|
// own configuration, and the browser has to know before it decides
|
||||||
// whether to draw a map or ask for two numbers.
|
// whether to draw a map or ask for two numbers.
|
||||||
site.maps = crate::maps::enabled();
|
site.maps = crate::maps::enabled();
|
||||||
|
site.sign_in = state.oidc.enabled();
|
||||||
Ok(site)
|
Ok(site)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -779,8 +779,13 @@ pub struct SiteConfig {
|
|||||||
/// the way to the browser (see `get_site`), never from content, so
|
/// the way to the browser (see `get_site`), never from content, so
|
||||||
/// a `type: location` field knows whether to offer a map or two
|
/// a `type: location` field knows whether to offer a map or two
|
||||||
/// number inputs.
|
/// number inputs.
|
||||||
#[serde(skip)]
|
#[serde(default)]
|
||||||
pub maps: bool,
|
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
|
/// Who this site's mail comes from. Absent means the site sends
|
||||||
/// nothing: a state's `mail:` and the invite mail are logged and
|
/// nothing: a state's `mail:` and the invite mail are logged and
|
||||||
/// skipped, never half-sent (see `mail.rs`).
|
/// skipped, never half-sent (see `mail.rs`).
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
|||||||
("en", "if_stuck") => " if you get stuck.",
|
("en", "if_stuck") => " if you get stuck.",
|
||||||
("en", "send") => "Send",
|
("en", "send") => "Send",
|
||||||
("en", "sign_in") => "Sign in",
|
("en", "sign_in") => "Sign in",
|
||||||
|
("en", "sign_out") => "Sign out",
|
||||||
("en", "nothing_here_yet") => "Nothing here yet.",
|
("en", "nothing_here_yet") => "Nothing here yet.",
|
||||||
("en", "nothing_here") => "Nothing here",
|
("en", "nothing_here") => "Nothing here",
|
||||||
("en", "back_to_start") => "back to the start",
|
("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", "if_stuck") => " om du står fast.",
|
||||||
("no", "send") => "Send",
|
("no", "send") => "Send",
|
||||||
("no", "sign_in") => "Logg inn",
|
("no", "sign_in") => "Logg inn",
|
||||||
|
("no", "sign_out") => "Logg ut",
|
||||||
("no", "nothing_here_yet") => "Ingenting her ennå.",
|
("no", "nothing_here_yet") => "Ingenting her ennå.",
|
||||||
("no", "nothing_here") => "Ingenting her",
|
("no", "nothing_here") => "Ingenting her",
|
||||||
("no", "back_to_start") => "tilbake til start",
|
("no", "back_to_start") => "tilbake til start",
|
||||||
@@ -98,6 +100,7 @@ mod tests {
|
|||||||
"if_stuck",
|
"if_stuck",
|
||||||
"send",
|
"send",
|
||||||
"sign_in",
|
"sign_in",
|
||||||
|
"sign_out",
|
||||||
"nothing_here_yet",
|
"nothing_here_yet",
|
||||||
"nothing_here",
|
"nothing_here",
|
||||||
"back_to_start",
|
"back_to_start",
|
||||||
|
|||||||
+59
-8
@@ -39,7 +39,7 @@ pub async fn get_resource(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| ServerFnError::new("feature has no resource"))?;
|
.ok_or_else(|| ServerFnError::new("feature has no resource"))?;
|
||||||
authorize_resource(&state, &question_id, &alternative, &feature_name).await?;
|
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` -
|
/// The `Requirement.resource`-backed counterpart to `get_resource` -
|
||||||
@@ -83,7 +83,7 @@ pub async fn get_requirement_options(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| ServerFnError::new("requirement has no resource"))?;
|
.ok_or_else(|| ServerFnError::new("requirement has no resource"))?;
|
||||||
authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}")).await?;
|
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
|
/// The `Requirement.bind`-backed counterpart to
|
||||||
@@ -114,7 +114,7 @@ pub async fn get_requirement_binding(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| ServerFnError::new("requirement has no binding"))?;
|
.ok_or_else(|| ServerFnError::new("requirement has no binding"))?;
|
||||||
authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}|bind")).await?;
|
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
|
/// 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
|
/// 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
|
/// not say" is not something a page should be able to ask for by
|
||||||
/// accident.
|
/// 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")]
|
#[cfg(feature = "ssr")]
|
||||||
fn matches_filter(
|
pub(crate) fn matches_filter(
|
||||||
resource: &crate::content::ResourceSpec,
|
resource: &crate::content::ResourceSpec,
|
||||||
value: &serde_json::Value,
|
value: &serde_json::Value,
|
||||||
params: &std::collections::HashMap<String, String>,
|
params: &std::collections::HashMap<String, String>,
|
||||||
@@ -220,19 +224,54 @@ fn matches_filter(
|
|||||||
let Some(filter) = &resource.filter else {
|
let Some(filter) = &resource.filter else {
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
let wanted = crate::content::template_action(&filter.equals, &serde_json::json!(params))
|
let equals = if filter.equals.contains("{user.email}") {
|
||||||
.unwrap_or_else(|| filter.equals.clone());
|
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
|
let found = value
|
||||||
.get("responses")
|
.get("responses")
|
||||||
.and_then(|r| r.get(&filter.field))
|
.and_then(|r| r.get(&filter.field))
|
||||||
.or_else(|| value.get(&filter.field));
|
.or_else(|| value.get(&filter.field));
|
||||||
match found {
|
match found {
|
||||||
Some(serde_json::Value::String(s)) => s == &wanted,
|
Some(serde_json::Value::String(s)) => same(s),
|
||||||
Some(other) => other.to_string() == wanted,
|
Some(other) => same(&other.to_string()),
|
||||||
None => false,
|
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")]
|
#[cfg(feature = "ssr")]
|
||||||
async fn fetch_resource_value(
|
async fn fetch_resource_value(
|
||||||
state: &crate::server::AppState,
|
state: &crate::server::AppState,
|
||||||
@@ -559,6 +598,18 @@ mod tests {
|
|||||||
assert!(!matches_filter(&children, &theirs, ¶ms));
|
assert!(!matches_filter(&children, &theirs, ¶ms));
|
||||||
// A record that did not say is not everyone's child.
|
// A record that did not say is not everyone's child.
|
||||||
assert!(!matches_filter(&children, &neither, ¶ms));
|
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<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
|
// No filter, no narrowing: every resource written before this
|
||||||
// existed still lists its whole bucket.
|
// existed still lists its whole bucket.
|
||||||
let plain = spec("source: { kind: kv, bucket: objections }\npublic: true\n");
|
let plain = spec("source: { kind: kv, bucket: objections }\npublic: true\n");
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ const NONCE_KEY: &str = "oidc_nonce";
|
|||||||
const REDIRECT_KEY: &str = "oidc_post_login_redirect";
|
const REDIRECT_KEY: &str = "oidc_post_login_redirect";
|
||||||
|
|
||||||
impl Oidc {
|
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
|
/// Sign-in disabled: what a content-only instance has, and what a
|
||||||
/// test builds an `AppState` with.
|
/// test builds an `AppState` with.
|
||||||
pub fn disabled() -> Self {
|
pub fn disabled() -> Self {
|
||||||
|
|||||||
+11
-6
@@ -277,13 +277,15 @@ pub async fn attachment(
|
|||||||
// cannot be used to learn which buckets, records or fields exist.
|
// cannot be used to learn which buckets, records or fields exist.
|
||||||
let missing = || (StatusCode::NOT_FOUND, "not found".to_string());
|
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 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 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
|
let key = answer
|
||||||
.responses
|
.responses
|
||||||
.get(&field)
|
.get(&field)
|
||||||
@@ -331,20 +333,23 @@ pub async fn attachment(
|
|||||||
/// Whether this caller may see this bucket at all: whether any feature
|
/// 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
|
/// 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.
|
/// feature. The desk that shows the record is what grants the file.
|
||||||
async fn may_read_bucket(
|
async fn may_read_record(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
session: &tower_sessions::Session,
|
session: &tower_sessions::Session,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
|
record: &serde_json::Value,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
use crate::auth::{User, SESSION_USER_KEY};
|
use crate::auth::{User, SESSION_USER_KEY};
|
||||||
|
|
||||||
let user = session.get::<User>(SESSION_USER_KEY).await.ok().flatten();
|
let user = session.get::<User>(SESSION_USER_KEY).await.ok().flatten();
|
||||||
let principal = crate::access::Principal::from_user(user.as_ref());
|
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();
|
let access = state.access.load();
|
||||||
state.questions.load().values().any(|question| {
|
state.questions.load().values().any(|question| {
|
||||||
question.alternatives.iter().any(|alternative| {
|
question.alternatives.iter().any(|alternative| {
|
||||||
alternative.features.iter().any(|feature| {
|
alternative.features.iter().any(|feature| {
|
||||||
feature.resource.as_ref().and_then(|r| r.bucket()) == Some(bucket)
|
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(
|
&& access.allows(
|
||||||
&principal,
|
&principal,
|
||||||
crate::access::Action::Read,
|
crate::access::Action::Read,
|
||||||
|
|||||||
+123
-11
@@ -173,6 +173,61 @@ main.not-found {
|
|||||||
gap: 1rem;
|
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 {
|
.wordmark {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
@@ -742,7 +797,7 @@ main.not-found {
|
|||||||
|
|
||||||
.feature p {
|
.feature p {
|
||||||
color: var(--ink-dim);
|
color: var(--ink-dim);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
@@ -753,7 +808,7 @@ main.not-found {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.field-label {
|
.field-label {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--ink-dim);
|
color: var(--ink-dim);
|
||||||
}
|
}
|
||||||
@@ -763,17 +818,74 @@ main.not-found {
|
|||||||
margin-left: 0.2em;
|
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,
|
input,
|
||||||
textarea {
|
textarea {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
border: 0.06rem solid var(--line);
|
border: 0.06rem solid var(--line);
|
||||||
border-radius: 0.6rem;
|
border-radius: 0.6em;
|
||||||
padding: 0.65rem 0.8rem;
|
padding: 0.65em 0.8em;
|
||||||
width: 100%;
|
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 {
|
textarea {
|
||||||
min-height: 5rem;
|
min-height: 5rem;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
@@ -1034,14 +1146,14 @@ textarea:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.alt-submit {
|
.alt-submit {
|
||||||
font-family: var(--sans);
|
font-family: inherit;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95em;
|
||||||
color: var(--paper);
|
color: var(--paper);
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.7rem;
|
border-radius: 0.7em;
|
||||||
padding: 0.75rem 1.4rem;
|
padding: 0.75em 1.4em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: filter 120ms, transform 120ms;
|
transition: filter 120ms, transform 120ms;
|
||||||
/* A gateway alternative renders this as an <a> (navigation, no POST);
|
/* A gateway alternative renders this as an <a> (navigation, no POST);
|
||||||
@@ -1247,13 +1359,13 @@ textarea:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.select-option {
|
.select-option {
|
||||||
font-family: var(--sans);
|
font-family: inherit;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85em;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
border: 0.06rem solid var(--line);
|
border: 0.06rem solid var(--line);
|
||||||
border-radius: 999rem;
|
border-radius: 999rem;
|
||||||
padding: 0.4rem 0.9rem;
|
padding: 0.45em 1em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 120ms, color 120ms, background 120ms;
|
transition: border-color 120ms, color 120ms, background 120ms;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user