Compare commits

...
16 Commits
Author SHA1 Message Date
Bendik Aagaard Lynghaug 827a75be3d Release 0.6.2
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m14s
Publish release / publish (push) Successful in 2m3s
2026-09-30 17:39:57 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 4e0a56be13 Changelog: 0.6.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:39:55 +02:00
bl 04204bef23 Merge pull request 'The header knows who is here; my cases; controls that scale' (#10) from header into main
docs / deploy (push) Successful in 2s
Test / test (push) Successful in 1m14s
2026-09-30 15:39:54 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 c766dedc0e A stored file on a record shows as its name and opens from the row
Test / test (pull_request) Successful in 1m16s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:38:32 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 a822f2175f Trainer: followed_by - a group whose members each see their own cases; file fields
Test / test (pull_request) Successful in 1m14s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:36:08 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 f2b131ebbb KANIDM_GROUP_PREFIX: plain group names in content on a shared Kanidm
Test / test (pull_request) Successful in 1m14s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:34:21 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 6b72ae568a The header knows who is here; my cases; controls that scale with the text
Test / test (pull_request) Successful in 1m12s
A session link top right on a site with sign-in, and on a page with
pictures the wordmark as its own bar over the strip. A list may be
narrowed to the signed-in person's own records ({user.email}, from the
session), and an attachment follows the list that shows its record.
Form controls are sized in em: the date picker's icon and the file
picker's button scale and take the field's colours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:32:41 +02:00
Bendik Aagaard Lynghaug 9a96386444 Release 0.6.1
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m7s
Publish release / publish (push) Successful in 1m46s
2026-09-30 17:19:36 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 5c95b6d8b3 Changelog: 0.6.1; the bare binary sorts after the tarball
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:19:34 +02:00
bl d3a9e79351 Merge pull request 'A slideshow across a page' (#9) from slideshow into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m8s
2026-09-30 15:19:33 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 c1a8433640 Slideshow: brace the closure
Test / test (pull_request) Successful in 1m8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:17:55 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 cfb050d3ef A slideshow across a page: pictures the full width of the screen, captions over them
Test / test (pull_request) Failing after 1m5s
slideshow on a page: max_height in svh, slides with an image (a path
in the content repo or https), a caption laid over the picture's foot,
and optionally the picture's size so its place is held before it
loads. Swiper upgrades it; before that it is a row that scrolls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:16:50 +02:00
bl fc708c3546 Merge pull request 'Trainer: documents - a text word for word, one card per section' (#8) from documents-as-cards into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m7s
2026-09-30 15:06:28 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 c7e0ee146f Trainer: documents - a text word for word, one card per section
Test / test (pull_request) Successful in 1m8s
Bylaws and terms are not the model's to write. documents in a plan
names a markdown file beside it; every heading with text under it is
one alternative on a page of its own, its paragraphs the card's words.
Tomter Vel's bylaws of 23.04.2018 are the first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:05:06 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 3bb517be0f Docs: vel.klingenbergbygg.no runs v0.6.0
docs / deploy (push) Successful in 2s
Test / test (push) Failing after 8s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 15:40:35 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 4eb1d03583 Changelog: 0.5.11 was tagged and never published
docs / deploy (push) Successful in 2s
Test / test (push) Failing after 35s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 15:38:53 +02:00
21 changed files with 876 additions and 50 deletions
+4 -2
View File
@@ -71,7 +71,9 @@ jobs:
cp -r target/site "$stage/site"
tar -C "$stage" -czf "portal-$tag.tar.gz" portal hash.txt site
# The bare binary as well: a content repo's CI runs `portal lint`
# and needs nothing else from the release.
# and needs nothing else from the release. Named to sort after
# the tarball: Gitea lists assets by name, and deploy scripts
# written before 0.6 take the first one.
cp "$stage/portal" portal-bin
rm -rf "$stage"
@@ -101,5 +103,5 @@ jobs:
"$api/releases/$id/assets?name=portal-$tag.tar.gz" > /dev/null
curl -sf -X POST -H "$auth" \
-F "attachment=@portal-bin" \
"$api/releases/$id/assets?name=portal" > /dev/null
"$api/releases/$id/assets?name=portal-x86_64-linux" > /dev/null
echo "published $tag"
+21 -2
View File
@@ -4,11 +4,30 @@ 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
content repos pin the tag they run (see the end of this file).
## 0.6.2 (2026-09-30)
- 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.
## 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.
- `slideshow` on a page: a strip of pictures the full width of the screen under the question, `max_height` in svh, each picture with its caption laid over it. Swiper when the bundle has loaded, a sideways-scrolling row before. `/site/` serves `jpeg`, and lets a browser keep photographs for an hour.
- The release's bare binary is named `portal-x86_64-linux` (it was `portal` in 0.6.0 only). Gitea lists assets by name, and deploy scripts that take the first asset got the binary instead of the tarball.
## 0.6.0 (2026-09-30)
- One binary. The lint (uhhm/iris) and the trainer (uhhm/portal-trainer) are commands of `portal`: `portal serve` (the default), `portal lint`, and `portal plan`, `build`, `answer`, `rate`, `prefer`, `score`, `simulate`, `compare`, `report`, `generate`. Same source, same version, so the site, the lint and the trainer never disagree about what content means; both histories are merged in. The release attaches the bare `portal` binary beside the tarball, so a content repo's CI downloads it and runs `portal lint`. No more iris releases; the pins that exist keep working. The trainer's `plans/`, `intakes/`, `engines/`, `sectors/` and its reference prompt live here now; its `runs/` stay in uhhm/portal-trainer. Docs gained "Training a site" and "The lint".
## 0.5.11 (2026-09-30)
## 0.5.11 (2026-09-30, tagged, never published)
The publish job failed at its lint step: iris, built against this tag, did not compile against the storylines change (the same two fixes that went into `src/lint/` when the lint moved in). Nothing was attached to the tag; 0.6.0 is the first release that ships storylines.
- Storylines (`src/content/variants.rs`, `docs/storylines.md`): any text a person reads may be a list, one entry per storyline, every list the same length; a visitor is given one storyline for the whole site (the `portal_storyline` cookie, a year) and reads it on every page and in every mail about their case. What the machine reads - a field's `name`, `options`, states, groups, paths - never varies. The lint refuses a list of another length. Records and answer events carry `storyline`, and `GET /report` gives counts per bucket, per storyline, per state, and nothing else.
@@ -408,4 +427,4 @@ hosts now, since kasse's runner deploys its own instances.
| redoal.com | ergo | `app@redoal-portal` | v0.5.2 |
| westra.klingenbergbygg.no | kasse | `app@westra-portal` | v0.5.2 |
| portal.klingenbergbygg.no | kasse | `app@klingenberg-portal` | v0.5.2 |
| vel.klingenbergbygg.no | kasse | `tomtervel-portal-1` (podman, tomtervel/infrastructure) | v0.5.10, memberships |
| vel.klingenbergbygg.no | kasse | `tomtervel-portal-1` (podman, tomtervel/infrastructure) | v0.6.0, memberships |
Generated
+1 -1
View File
@@ -3357,7 +3357,7 @@ dependencies = [
[[package]]
name = "portal"
version = "0.6.0"
version = "0.6.2"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "portal"
version = "0.6.0"
version = "0.6.2"
edition = "2021"
[lib]
+24
View File
@@ -76,6 +76,26 @@ nearest `_section.yaml`. `event` makes a page an occasion with a time;
opening any `type: location` field on the page there rather than on
the whole world.
### Pictures across the page
`slideshow` lays a strip of pictures under the question, the full
width of the screen: every picture as tall as the strip and as wide as
its own shape, swiped or arrowed through, its caption over its foot.
```yaml
slideshow:
max_height: 60 # svh; 60 unless set, between 20 and 100
slides:
- image: images/built/01.webp # a path in the content repo, or https://
caption: The canteen at the theatre, renewed
width: 3207 # the picture's own size, optional:
height: 1400 # holds its place before it has loaded
```
Pictures in the content repo are served from this origin under
`/site/`; `webp`, `avif` and `jpeg` may be kept by the browser for an
hour. Keep them web-sized: around 1400 pixels tall is plenty.
## An alternative
One choice. Never two actions behind one heading: if two things can be
@@ -243,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
+1 -1
View File
@@ -15,7 +15,7 @@ content repo's CI downloads the release and runs it:
```yaml
- run: |
curl -sfL "https://project.uhhm.no/uhhm/portal/releases/download/$PORTAL_RELEASE/portal" -o portal
curl -sfL "https://project.uhhm.no/uhhm/portal/releases/download/$PORTAL_RELEASE/portal-x86_64-linux" -o portal
chmod +x portal && ./portal lint --path questions
```
+1
View File
@@ -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 `<prefix>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`) |
+88
View File
@@ -0,0 +1,88 @@
# Vedtekter
### av 23.04.2018
##### § 1
Tomter vel er upolitisk. Foreningen har som formål å virke for stedets velferd, forskjønnelse og utvikling og ellers ivareta Tomters interesser.
##### § 2
Alle som er bosatt i Tomter kan bli medlem av foreningen. Midlertidig utflyttede medlemmer kan beholde sitt medlemskap.
##### § 3
Årsmøte, som er vellets øverste myndighet, holdes innen utgangen av mars hvert år. Møtet kunngjøres med minst tre ukers varsel. Forslag skal også gjøres kjent minst to uker før møtet. Årsmøte godkjenner beretning, regnskap og budsjett. Den velger styre, komitéledere, og valgkomite.
##### § 4.
Ekstraordinært årsmøte holdes når styret, eller minst 15 medlemmer krever det i skriftlig form. Gjennomføringen følger samme prosedyre som ordinært årsmøte
##### § 5
Tomter Vel ledes av et styre på seks medlemmer. Leder, nestleder, sekretær, kasserer og to styremedlemmer, som alle velges på årsmøtet. Funksjonstiden er to år. Leder, sekretær og ett styremedlem velges det ene året, nestleder, kasserer og det andre styremedlemmet det andre året. Det skal dessuten på årsmøte velges ett varamedlem til styret med funksjonstid to år. Styret engasjerer revisor eller ekstern regnskapsfører. Oppnevning av andre komitémedlemmer og representanter foretas av styret etter behov. Komitéledere har møterett på styremøter.
##### § 6
Medlemsmøte avholdes når styret finner det nødvendig og skal bekjentgjøres minst en uke i forveien. Det bør avholdes minst ett medlemmsmøte mellom hvert årsmøte.
##### § 7.1
Styret skal:
iverksette årsmøtets vedtak og bestemmelser.
Stå for velforeningens daglige ledelse og ivareta medlemmenes interesser overfor andre instanser.
Stå for den alminnelige forvaltningen av foreningen, herunder forvalte velforeningens eiendeler og føre kontroll med foreningens økonomi.
etter behov opprette komitéer eller bemyndige personer for å løse spesielle oppgaver.
etter beste evne søke å oppfylle foreningens formål eller gjeldende regler.
sørge for at foreningen er forsvarlig forsikret.
##### § 7.2
Styret har disposisjonsrett over velforeningens midler til alminnelige administrative formål og rett til å bevilge beløp til gjennomføring av vedtak på årsmøtet. Alle utbetalinger skal attesteres av leder og kasserer før utbetaling. Styret har imidlertid ikke adgang til uten samtykke fra årsmøtet å treffe beslutninger av større betydning for foreningen.
##### § 7.3
Styret holder møte når leder bestemmer eller et flertall av styremedlemmene forlanger det. Varamedlemmet kan møte på samtlige styremøter, men har kun stemmerett når styret ikke er fulltallig.
Alle saker og all korrespondanse skal legges fram på styremøtet. Styrets vedtak skal protokolleres. Det skal fremgå av protokollen om vedtaket er enstemmig, og eventuelle dissenser protokolleres. Alle medlemmene har rett til innsyn i protokollene fra styremøtene. Innsyn kan nektes av styret i enkeltsaker, dersom hensynet til tredjemann tilsier det eller dersom andre saklige grunner for nektelse foreligger.
##### § 7.4
Styret er beslutningsdyktig når et flertall av styremedlemmene, deriblant leder eller nestleder, er tilstede. Leder eller i hans/hennes fravær nestleder, leder styrets møter. Styrebeslutninger krever alminnelig flertall. Ved stemmelikhet har fungerende leder dobbeltstemme.
Et styremedlem kan ikke være med å stemme i saker hvor dette har særinteresser.
##### § 8
Regnskapsåret går fra 01.01–31.12. Styret skal påse at regnskapet blir ført.
##### § 9
Medlemskontingenten er den som til enhver tid er/blir fastsatt av Årsmøte. Kontingenten innkreves av styret. Den betales forskuddsvis.
##### § 10
Tomter Vel kan bare oppløses ved vedtak på Årsmøte etter forslag fra styret. Til slikt vedtak kreves to tredjedels stemmeflertall blant de frammøtte medlemmer. Forslag om oppløsning skal gjøres kjent 3 uker før årsmøtet holdes. Eventuelle midler som Tomter Vel sitter inne med, anvendes da straks til formål som kommer Tomter til gode.
##### § 11
Styret eller årsmøtet oppretter komiteer for definerte formål. Komitéer skal bestå av komiteleder som rapporterer til styret. Komiteleder tilknytter et egnet antall personer til formålet og organiserer arbeidet.
##### § 12
Valgkomiteen består av to medlemmer med funksjonstid ett år. Valgkomiteen foreslår overfor Årsmøte medlemmer og varamedlemmer til styre, komitéer og valgkomite, samt revisor.
##### § 13
Tomter Vel skal ha en revisorer med funksjonstid to år. Revisoren skal revidere regnskapet og legge fram revisjonsberetning for Årsmøtet.
##### § 14
Forandringer i disse lover kan bare skje ved vedtak på Årsmøte. Slikt vedtak krever to tredjedels stemmeflertall blant de frammøtte medlemmene.
+8
View File
@@ -62,6 +62,14 @@ does:
nabohjelpsgruppa deg med en nabo som kan. Du sier selv fra når
det er ordnet.
# Vedtektene, ord for ord slik årsmøtet vedtok dem: en side der hver
# paragraf er sitt eget kort. Ingen modell rører teksten.
documents:
- id: vedtekter
question: Hva sier vedtektene?
description: Vedtektene for Tomter Vel, av 23.04.2018.
file: documents/tomtervel-vedtekter.md
doors:
- id: medlem
who: en husstand i Tomter som vil bli medlem, betale kontingent, eller melde flytting, ny e-post eller utmelding
+139 -6
View File
@@ -369,7 +369,7 @@ fn QuestionView(
// Swiper Element only registers its custom elements when its
// (vendored, ~180KB) script runs - loaded once per page, and only
// on pages where some alternative actually declares a deck.
let needs_swiper = question.alternatives.iter().any(|a| a.images.len() > 1);
let needs_swiper = question.slideshow.is_some() || question.alternatives.iter().any(|a| a.images.len() > 1);
// MapLibre is vendored and large, so it loads only where a field
// actually asks for a place, and only where this instance can
// serve tiles at all.
@@ -396,6 +396,8 @@ fn QuestionView(
landing=question_id == "/"
site=site.clone()
current_id=question_id.clone()
user=user.clone()
slideshow=question.slideshow.clone()
/>
// A page that is about somewhere says where, under the
// question: a still from this origin, and the place in words
@@ -841,7 +843,15 @@ mod gesture {
}
#[component]
fn Hero(title: String, description: String, landing: bool, site: SiteConfig, current_id: String) -> impl IntoView {
fn Hero(
title: String,
description: String,
landing: bool,
site: SiteConfig,
current_id: String,
#[prop(optional_no_strip)] user: Option<User>,
#[prop(optional_no_strip)] slideshow: Option<crate::content::Slideshow>,
) -> impl IntoView {
// Only the landing page gets a piece - it's the one page a hero is
// actually "the" hero for; every other page gets the plain header.
// WHAT the piece is belongs to the content repo: site.yaml's
@@ -940,9 +950,37 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur
)
});
// Who is here, and the way in or out, at the top of every page of
// a site that has sign-in at all.
let session = site.sign_in.then(|| {
let redirect = current_id.clone();
match user {
Some(user) => view! {
<nav class="session">
<span class="session-name">{user.display_name}</span>
<a href="/auth/logout" rel="external">{move || t(&lang().get(), "sign_out")}</a>
</nav>
}
.into_any(),
None => view! {
<nav class="session">
<a href=format!("/auth/login?redirect={redirect}") rel="external">{move || t(&lang().get(), "sign_in")}</a>
</nav>
}
.into_any(),
}
});
let has_slides = slideshow.is_some();
let mark = move || view! {
<a class="wordmark" class:wordmark-house=house_wordmark href="/">
<img src=wordmark.clone() alt=site_title.clone() style=wordmark_style.clone()/>
</a>
};
view! {
<header class="hero" class:hero-module=has_module>
<header class="hero" class:hero-module=has_module class:hero-slides=has_slides>
<Announcements current_id=current_id/>
{session}
{has_module
.then(|| {
view! {
@@ -950,10 +988,13 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur
<script type="module" inner_html=early_script.unwrap_or_default()></script>
}
})}
// With pictures, the wordmark is a bar of its own above
// them and the question comes under; without, the wordmark
// heads the copy as it always has.
{has_slides.then(|| view! { <div class="hero-bar">{mark()}</div> })}
{slideshow.map(|show| view! { <SlideStrip show=show/> })}
<div class="hero-copy">
<a class="wordmark" class:wordmark-house=house_wordmark href="/">
<img src=wordmark alt=site_title style=wordmark_style/>
</a>
{(!has_slides).then(|| mark())}
<h1>{title}</h1>
<p inner_html=render_inline_markdown(&description)></p>
</div>
@@ -961,6 +1002,59 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur
}
}
/// Pictures across the page (`slideshow`): the full width of the
/// screen, each picture as wide as its own shape at the strip's height,
/// the caption laid over its foot. Inert until the Swiper bundle
/// upgrades the elements; without it, a row that scrolls sideways.
#[component]
fn SlideStrip(show: crate::content::Slideshow) -> impl IntoView {
let height = format!("--slideshow-height: {}svh", show.max_height);
view! {
// In the page itself, so the strip works from the
// server's HTML alone; the head's copy above covers
// arriving here by a link within the site.
<script src="/swiper-element-bundle.min.js" defer=true></script>
<swiper-container
class="slideshow"
style=height
slides-per-view="auto"
space-between="6"
grab-cursor="true"
free-mode="true"
mousewheel-force-to-axis="true"
keyboard="true"
navigation="true"
>
<For
each={move || show.slides.clone().into_iter().enumerate().collect::<Vec<_>>()}
key=|(_, slide)| slide.image.clone()
children=|(n, slide): (usize, crate::content::Slide)| {
let caption = slide.caption.clone();
// The first screenful loads with the
// page; the rest when swiped to.
let loading = if n < 4 { "eager" } else { "lazy" };
view! {
<swiper-slide>
<figure class="slide">
<img
src=slide.src()
alt=slide.caption.clone()
width=slide.width.map(|w| w.to_string())
height=slide.height.map(|h| h.to_string())
loading=loading
decoding="async"
/>
{(!caption.trim().is_empty()).then(|| view! { <figcaption>{caption}</figcaption> })}
</figure>
</swiper-slide>
}
.into_any()
}
/>
</swiper-container>
}
}
/// An alternative's banner imagery: nothing, a plain image, or - for
/// several urls - a Swiper Element card deck. The `swiper-container`/
/// `swiper-slide` custom elements render inert server-side and upgrade
@@ -1306,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
@@ -1759,6 +1854,7 @@ fn ResourceFeature(
feature_name: String,
transitions: Vec<Transition>,
link: Option<RowLink>,
bucket: Option<String>,
empty: Option<String>,
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
transition_batch: ServerAction<TransitionAnswers>,
@@ -1794,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 {
@@ -1803,6 +1900,7 @@ fn ResourceFeature(
feature_name=feature_name
transitions=transitions
link=link
bucket=bucket
empty=empty
value=value
pending_transitions=pending_transitions
@@ -1831,6 +1929,7 @@ fn ResourceValue(
feature_name: String,
transitions: Vec<Transition>,
link: Option<RowLink>,
bucket: Option<String>,
empty: String,
value: serde_json::Value,
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
@@ -1863,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! {
<AnswerRow
feature_name=feature_name.clone()
transitions=transitions.clone()
link=link.clone()
bucket=bucket.clone()
answer=answer
pending_transitions=pending_transitions
transition_batch=transition_batch
@@ -2123,11 +2224,20 @@ fn SelectField(
}
}
/// The file's own name, if a field's value is an upload's key: a
/// uuid, a dash, and the name it was sent with (`upload.rs`).
pub fn uploaded_filename(value: &str) -> 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<Transition>,
link: Option<RowLink>,
bucket: Option<String>,
answer: Answer,
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
transition_batch: ServerAction<TransitionAnswers>,
@@ -2161,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! {
<article class="answer-row" class:decided=move || decided>
@@ -2194,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! {
<div class="answer-field">
<span class="answer-field-key">{key}</span>
<a class="answer-field-value answer-file" href=href rel="external">{filename.to_string()}</a>
</div>
}
.into_any();
}
view! {
<div class="answer-field">
<span class="answer-field-key">{key}</span>
@@ -2407,6 +2531,7 @@ pub async fn get_site() -> Result<SiteConfig, ServerFnError> {
// own configuration, and the browser has to know before it decides
// whether to draw a map or ask for two numbers.
site.maps = crate::maps::enabled();
site.sign_in = state.oidc.enabled();
Ok(site)
}
@@ -2933,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)));
+6 -1
View File
@@ -74,14 +74,19 @@ pub async fn site_asset_handler(
Some("svg") => "image/svg+xml",
Some("json") => "application/json",
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("webp") => "image/webp",
Some("avif") => "image/avif",
Some("ico") => "image/x-icon",
Some("woff2") => "font/woff2",
_ => "application/octet-stream",
};
// Photographs are large and do not change under a name, so a
// browser may keep them for an hour; everything else revalidates,
// so a content push shows on reload.
let cache = if matches!(mime, "image/webp" | "image/avif" | "image/jpeg") { "public, max-age=3600" } else { "no-cache" };
(
[(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, "no-cache")],
[(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)],
bytes,
)
.into_response()
+67 -1
View File
@@ -95,6 +95,12 @@ pub struct Question {
/// rather than on the whole world.
#[serde(default)]
pub place: Option<Place>,
/// Pictures across the page, under the question: a strip the full
/// width of the screen that a visitor swipes through, each picture
/// with its caption laid over it. What the business has built,
/// shown rather than described.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slideshow: Option<Slideshow>,
}
/// A point a page is about. `zoom` is a map zoom level, 0 (the globe)
@@ -625,6 +631,50 @@ pub struct Requirement {
/// listing may ask about a record's own contents: one field, one
/// value, compared as strings. Anything more is a query language, and
/// a jq filter is already there for shaping what comes back.
/// A strip of pictures across a page (`Question.slideshow`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Slideshow {
/// How tall the strip may be, in hundredths of the small viewport
/// height (`svh`): 60 is six tenths of the screen. Every picture is
/// that tall and as wide as its own shape makes it.
#[serde(default = "default_slideshow_height")]
pub max_height: u8,
pub slides: Vec<Slide>,
}
fn default_slideshow_height() -> u8 {
60
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Slide {
/// A path in the content repo (`images/built/01.webp`, served from
/// this origin under `/site/`) or an `https://` address.
pub image: String,
/// Laid over the foot of the picture, and its alternative text.
#[serde(default)]
pub caption: String,
/// The picture's own size in pixels, when known: the browser then
/// holds its place in the strip before it has loaded, so pictures
/// further along can wait until they are swiped to.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub width: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub height: Option<u32>,
}
impl Slide {
/// Where the browser fetches the picture: https as given, anything
/// else as a content-repo path under `/site/`.
pub fn src(&self) -> String {
if self.image.starts_with("https://") || self.image.starts_with("/site/") {
self.image.clone()
} else {
format!("/site/{}", self.image.trim_start_matches('/'))
}
}
}
/// A link from every listed record to a page about it (`ResourceSpec.link`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RowLink {
@@ -729,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`).
@@ -1904,6 +1959,17 @@ alternatives:
assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("text", None));
}
#[test]
fn a_slide_is_fetched_from_this_origin_unless_it_is_https() {
let q: Question = serde_yaml::from_str("name: Q?\nslideshow:\n slides:\n - { image: images/built/01.webp, caption: The canteen, width: 3207, height: 1400 }\n - { image: \"https://cdn.example/a.jpg\" }\n").unwrap();
let show = q.slideshow.unwrap();
assert_eq!(show.max_height, 60);
assert_eq!(show.slides[0].src(), "/site/images/built/01.webp");
assert_eq!(show.slides[0].width, Some(3207));
assert_eq!(show.slides[1].src(), "https://cdn.example/a.jpg");
assert_eq!(show.slides[1].caption, "");
}
#[test]
fn markdown_drops_html_and_unsafe_links() {
assert_eq!(render_inline_markdown("x <script>y</script> z"), "x y z");
+19
View File
@@ -146,6 +146,25 @@ pub fn validate_questions(
}
}
// A slideshow shows something, at a height that leaves the page
// its question: between a fifth and the whole of the screen.
for question in questions.values() {
if let Some(show) = &question.slideshow {
if show.slides.is_empty() {
anyhow::bail!("question {:?}: slideshow has no slides", question.id);
}
if !(20..=100).contains(&show.max_height) {
anyhow::bail!("question {:?}: slideshow max_height is {} svh; between 20 and 100", question.id, show.max_height);
}
for slide in &show.slides {
let ok = slide.image.starts_with("https://") || crate::content::is_safe_site_path(slide.image.trim_start_matches("/site/").trim_start_matches('/'));
if !ok {
anyhow::bail!("question {:?}: slideshow image {:?} is neither an https address nor a path in the content repo", question.id, slide.image);
}
}
}
}
// A mail to the record this one belongs to goes through a field of
// the form: `{notice.email}` needs a `type: record` field called
// `notice` on every form that records into the bucket, or the mail
+3
View File
@@ -22,6 +22,7 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
("en", "if_stuck") => " if you get stuck.",
("en", "send") => "Send",
("en", "sign_in") => "Sign in",
("en", "sign_out") => "Sign out",
("en", "nothing_here_yet") => "Nothing here yet.",
("en", "nothing_here") => "Nothing here",
("en", "back_to_start") => "back to the start",
@@ -51,6 +52,7 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
("no", "if_stuck") => " om du står fast.",
("no", "send") => "Send",
("no", "sign_in") => "Logg inn",
("no", "sign_out") => "Logg ut",
("no", "nothing_here_yet") => "Ingenting her ennå.",
("no", "nothing_here") => "Ingenting her",
("no", "back_to_start") => "tilbake til start",
@@ -98,6 +100,7 @@ mod tests {
"if_stuck",
"send",
"sign_in",
"sign_out",
"nothing_here_yet",
"nothing_here",
"back_to_start",
+11 -4
View File
@@ -184,6 +184,12 @@ fn name_from_search(body: &str) -> Option<String> {
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<Self> {
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<serde_json::Value>) -> 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<Vec<String>> {
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}");
+59 -8
View File
@@ -39,7 +39,7 @@ pub async fn get_resource(
.as_ref()
.ok_or_else(|| ServerFnError::new("feature has no resource"))?;
authorize_resource(&state, &question_id, &alternative, &feature_name).await?;
fetch_resource_value(&state, resource, &params).await
fetch_resource_value(&state, resource, &as_the_person_asking(params).await).await
}
/// The `Requirement.resource`-backed counterpart to `get_resource` -
@@ -83,7 +83,7 @@ pub async fn get_requirement_options(
.as_ref()
.ok_or_else(|| ServerFnError::new("requirement has no resource"))?;
authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}")).await?;
fetch_resource_value(&state, resource, &params).await
fetch_resource_value(&state, resource, &as_the_person_asking(params).await).await
}
/// The `Requirement.bind`-backed counterpart to
@@ -114,7 +114,7 @@ pub async fn get_requirement_binding(
.as_ref()
.ok_or_else(|| ServerFnError::new("requirement has no binding"))?;
authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}|bind")).await?;
fetch_resource_value(&state, &bind.resource, &params).await
fetch_resource_value(&state, &bind.resource, &as_the_person_asking(params).await).await
}
/// Scoped to the named alternative first, not flattened across all of
@@ -211,8 +211,12 @@ fn shows_state(resource: &crate::content::ResourceSpec, value: &serde_json::Valu
/// stamped on it. A missing field never matches: "everything that did
/// not say" is not something a page should be able to ask for by
/// accident.
///
/// `equals: "{user.email}"` is the signed-in person's own address: a
/// list of "my cases". It is filled from the session, never from the
/// caller, and with nobody signed in it matches nothing.
#[cfg(feature = "ssr")]
fn matches_filter(
pub(crate) fn matches_filter(
resource: &crate::content::ResourceSpec,
value: &serde_json::Value,
params: &std::collections::HashMap<String, String>,
@@ -220,19 +224,54 @@ fn matches_filter(
let Some(filter) = &resource.filter else {
return true;
};
let wanted = crate::content::template_action(&filter.equals, &serde_json::json!(params))
.unwrap_or_else(|| filter.equals.clone());
let equals = if filter.equals.contains("{user.email}") {
match params.get(USER_EMAIL).filter(|e| !e.is_empty()) {
Some(email) => filter.equals.replace("{user.email}", email),
None => return false,
}
} else {
filter.equals.clone()
};
let wanted = crate::content::template_action(&equals, &serde_json::json!(params)).unwrap_or(equals);
// Addresses compare without case; everything else as written.
let same = |found: &str| if filter.equals.contains("{user.email}") { found.trim().eq_ignore_ascii_case(&wanted) } else { found == wanted };
let found = value
.get("responses")
.and_then(|r| r.get(&filter.field))
.or_else(|| value.get(&filter.field));
match found {
Some(serde_json::Value::String(s)) => s == &wanted,
Some(other) => other.to_string() == wanted,
Some(serde_json::Value::String(s)) => same(s),
Some(other) => same(&other.to_string()),
None => false,
}
}
/// The parameter a `where` reads the signed-in person's address from.
/// Set by the server from the session; a caller's own is thrown away.
#[cfg(feature = "ssr")]
pub(crate) const USER_EMAIL: &str = "__user_email";
/// The caller's parameters with the session's person put in and
/// anything pretending to be them taken out.
#[cfg(feature = "ssr")]
pub(crate) fn with_person(mut params: std::collections::HashMap<String, String>, user: Option<&crate::auth::User>) -> std::collections::HashMap<String, String> {
params.retain(|k, _| !k.starts_with("__"));
if let Some(email) = user.and_then(|u| u.email.as_deref()).filter(|e| !e.trim().is_empty()) {
params.insert(USER_EMAIL.to_string(), email.trim().to_lowercase());
}
params
}
#[cfg(feature = "ssr")]
async fn as_the_person_asking(params: std::collections::HashMap<String, String>) -> std::collections::HashMap<String, String> {
use crate::auth::{User, SESSION_USER_KEY};
let user = match leptos_axum::extract::<tower_sessions::Session>().await {
Ok(session) => session.get::<User>(SESSION_USER_KEY).await.ok().flatten(),
Err(_) => None,
};
with_person(params, user.as_ref())
}
#[cfg(feature = "ssr")]
async fn fetch_resource_value(
state: &crate::server::AppState,
@@ -559,6 +598,18 @@ mod tests {
assert!(!matches_filter(&children, &theirs, &params));
// A record that did not say is not everyone's child.
assert!(!matches_filter(&children, &neither, &params));
// "My cases": the session's address, never the caller's.
let own = spec("source: { kind: kv, bucket: cases }\nrequires_group: customers\nwhere: { field: email, equals: \"{user.email}\" }\n");
let hers = serde_json::json!({ "responses": { "email": "Kari@Vel.no" } });
let his = serde_json::json!({ "responses": { "email": "ola@vel.no" } });
let kari = crate::auth::User { sub: "s".into(), username: "kari".into(), display_name: "Kari".into(), groups: vec![], phone: None, email: Some("kari@vel.no".into()) };
let forged: std::collections::HashMap<String, String> = [(USER_EMAIL.to_string(), "ola@vel.no".to_string())].into_iter().collect();
let asked = with_person(forged.clone(), Some(&kari));
assert!(matches_filter(&own, &hers, &asked) && !matches_filter(&own, &his, &asked));
// Nobody signed in: the forged parameter is dropped, and nothing matches.
let anonymous = with_person(forged, None);
assert!(!matches_filter(&own, &hers, &anonymous) && !matches_filter(&own, &his, &anonymous));
// No filter, no narrowing: every resource written before this
// existed still lists its whole bucket.
let plain = spec("source: { kind: kv, bucket: objections }\npublic: true\n");
+5
View File
@@ -50,6 +50,11 @@ const NONCE_KEY: &str = "oidc_nonce";
const REDIRECT_KEY: &str = "oidc_post_login_redirect";
impl Oidc {
/// Whether a provider is configured.
pub fn enabled(&self) -> bool {
self.inner.is_some()
}
/// Sign-in disabled: what a content-only instance has, and what a
/// test builds an `AppState` with.
pub fn disabled() -> Self {
+205 -6
View File
@@ -62,6 +62,12 @@ pub struct Plan {
/// is what the page-load limit asks for.
#[serde(default)]
pub doors: Vec<Door>,
/// Texts the site carries word for word - bylaws, terms - each as
/// a page of its own where every section of the text is one
/// alternative: its heading the card's, its paragraphs the card's
/// words. No model touches them and no form hangs off them.
#[serde(default)]
pub documents: Vec<Document>,
/// Groups with a label the public may see: the committees a
/// person can ask to join. A select field with `options_from:
/// groups` offers exactly these, so the form and the desks can
@@ -158,6 +164,85 @@ pub struct GroupDef {
pub invited_by: Vec<String>,
}
/// A text carried word for word (`Plan.documents`).
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Document {
/// The page's path: `vedtekter` is served at `/vedtekter`.
pub id: String,
/// The question the page asks, which the text answers.
pub question: String,
#[serde(default)]
pub description: String,
/// A markdown file beside the plan: every heading with text under
/// it is a section. Read into `sections` when the plan is loaded.
#[serde(default)]
pub file: Option<String>,
#[serde(default)]
pub sections: Vec<Section>,
}
#[derive(Debug, Deserialize, Clone, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Section {
pub heading: String,
/// The section's paragraphs, exactly as written.
pub text: Vec<String>,
}
/// A markdown text as sections: each heading with text under it, and
/// that text as paragraphs. A heading with nothing under it is a title
/// and no section; text before the first heading belongs to nobody and
/// is left out. Nothing is reworded: a paragraph's lines are joined
/// with one space, and that is all.
pub fn sections_of(markdown: &str) -> Vec<Section> {
let mut out: Vec<Section> = Vec::new();
let mut heading: Option<String> = None;
let mut paragraphs: Vec<String> = Vec::new();
let mut current: Vec<&str> = Vec::new();
let mut close = |heading: &mut Option<String>, paragraphs: &mut Vec<String>, out: &mut Vec<Section>| {
if let Some(h) = heading.take() {
if !paragraphs.is_empty() {
out.push(Section { heading: h, text: std::mem::take(paragraphs) });
}
}
paragraphs.clear();
};
for line in markdown.lines().chain(std::iter::once("")) {
let trimmed = line.trim();
let is_heading = trimmed.starts_with('#') && trimmed.trim_start_matches('#').starts_with(' ');
if trimmed.is_empty() || is_heading {
if !current.is_empty() {
paragraphs.push(current.join(" "));
current.clear();
}
}
if is_heading {
close(&mut heading, &mut paragraphs, &mut out);
heading = Some(trimmed.trim_start_matches('#').trim().trim_end_matches('.').to_string());
} else if !trimmed.is_empty() {
current.push(trimmed);
}
}
close(&mut heading, &mut paragraphs, &mut out);
out
}
impl Plan {
/// Reads every document's `file`, relative to `dir`, into its
/// sections. Done once, when a plan is loaded from disk.
pub fn load_documents(&mut self, dir: &Path) -> anyhow::Result<()> {
for doc in &mut self.documents {
if let Some(file) = &doc.file {
let path = dir.join(file);
let raw = std::fs::read_to_string(&path).with_context(|| format!("document {:?}: reading {}", doc.id, path.display()))?;
doc.sections = sections_of(&raw);
}
}
Ok(())
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Door {
@@ -258,6 +343,14 @@ pub struct PlanNeed {
/// Groups that see these cases on their desk and move nothing.
#[serde(default)]
pub watched_by: Vec<String>,
/// 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<String>,
/// 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
@@ -347,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"
}
}
@@ -389,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 == '-')
@@ -410,6 +503,17 @@ impl Plan {
bail!("contact: group {group:?} has no desk: it must handle, watch or move something");
}
}
for doc in &self.documents {
if !is_id(&doc.id) || ["status", "received", "decide"].contains(&doc.id.as_str()) || groups(self).contains(&doc.id) || self.doors.iter().any(|d| d.id == doc.id) {
bail!("document {:?}: id must be ASCII lowercase with - or _, and a path no door, group or builder page has", doc.id);
}
if doc.question.trim().is_empty() {
bail!("document {:?}: the page needs the question its text answers", doc.id);
}
if doc.sections.is_empty() {
bail!("document {:?} has no sections: give it a `file` with headings, or `sections`", doc.id);
}
}
let mut doors = BTreeSet::new();
for door in &self.doors {
if !is_id(&door.id) || !doors.insert(&door.id) {
@@ -591,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")));
@@ -1088,7 +1200,9 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
// 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.")
@@ -1384,7 +1498,16 @@ fn escalated_states_for(need: &PlanNeed, group: &str) -> Vec<String> {
/// 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<String> {
@@ -1397,6 +1520,7 @@ fn groups(plan: &Plan) -> BTreeSet<String> {
.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()
}
@@ -2058,6 +2182,10 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
if !escalated.is_empty() {
resource["states"] = json!(escalated);
}
// A group that follows its own cases sees those alone.
if follows_own(needs[0], &group) {
resource["where"] = json!({ "field": "email", "equals": "{user.email}" });
}
alternatives.push(json!({
"name": text(&bw["heading"]),
"description": text(&bw["description"]),
@@ -2160,6 +2288,27 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
);
}
// Documents: a public page per text, one alternative per section,
// every word the plan's own. The first paragraph is the card's
// description; any further ones follow as unnamed features.
for doc in &plan.documents {
files.insert(
format!("questions/{}.yaml", doc.id),
json!({
"name": doc.question.trim(),
"description": doc.description.trim(),
"responsible": responsible,
"alternatives": doc.sections.iter().map(|section| {
let mut alt = json!({ "name": section.heading, "description": section.text.first().cloned().unwrap_or_default() });
if section.text.len() > 1 {
alt["features"] = json!(section.text[1..].iter().map(|p| json!({ "name": "", "description": p })).collect::<Vec<_>>());
}
alt
}).collect::<Vec<_>>(),
}),
);
}
// Public views: an ungated nav page per opened bucket, listing it
// read-only, with contact details and private fields projected
// away. Anyone can see what was sent in and where it stands.
@@ -2317,8 +2466,9 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
let usage = "usage: portal-trainer build --plan <plan.yaml> --out <dir> [--words-from <existing repo>] [--words <words.json>]... [--llm 'cmd'] [--lint question_lint]";
let plan_path = value("--plan").ok_or_else(|| anyhow!(usage))?;
let out = std::path::PathBuf::from(value("--out").ok_or_else(|| anyhow!(usage))?);
let plan: Plan = serde_yaml::from_str(&std::fs::read_to_string(&plan_path).with_context(|| format!("reading {plan_path}"))?)
let mut plan: Plan = serde_yaml::from_str(&std::fs::read_to_string(&plan_path).with_context(|| format!("reading {plan_path}"))?)
.with_context(|| format!("parsing {plan_path}"))?;
plan.load_documents(Path::new(&plan_path).parent().unwrap_or_else(|| Path::new(".")))?;
plan.validate()?;
for warning in plan.spectators() {
eprintln!("build: {warning}");
@@ -2452,7 +2602,7 @@ mod tests {
lands_in: "b".into(), handled_by: "g".into(),
stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable),
moves_by: BTreeMap::new(), watched_by: vec![],
events: BTreeMap::new(), attended_by: None, mails: BTreeMap::new(), stages_text: BTreeMap::new(), turned_down_from: vec![], reached_from: BTreeMap::new(), requires: String::new(),
events: BTreeMap::new(), attended_by: None, mails: BTreeMap::new(), stages_text: BTreeMap::new(), turned_down_from: vec![], reached_from: BTreeMap::new(), requires: String::new(), followed_by: vec![],
grants: BTreeMap::new(), deadlines: BTreeMap::new(), escalate: BTreeMap::new(), public_states: vec![],
collects: vec![], max_fields: 3,
}
@@ -3267,6 +3417,55 @@ mod invite_tests {
assert!(blank.validate().unwrap_err().to_string().contains("tones"));
}
#[test]
fn a_group_that_follows_sees_its_own_cases_and_a_form_may_take_a_file() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nmail: { from: post@b.example }\nneeds:\n - id: job\n who: an owner\n wants: a report\n lands_in: jobs\n handled_by: office\n followed_by: [customers]\n stages: [open, accepted, delivered]\n done_when: [delivered]\n grants: { accepted: customers }\n max_fields: 3\n collects:\n - { name: name }\n - { name: email, type: email }\n - { name: drawings, type: file, optional: true }\n";
let plan: Plan = serde_yaml::from_str(yaml).unwrap();
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
let mine: Value = serde_yaml::from_str(&files["questions/customers/index.yaml"]).unwrap();
let resource = &mine["alternatives"][0]["features"][0]["resource"];
assert_eq!(resource["where"], json!({ "field": "email", "equals": "{user.email}" }));
assert_eq!(resource["transitions"], json!([]));
// The office sees everything.
let office: Value = serde_yaml::from_str(&files["questions/office/index.yaml"]).unwrap();
assert!(office["alternatives"][0]["features"][0]["resource"].get("where").is_none());
// The form takes the file.
assert!(files["questions/index.yaml"].contains("type: file"));
// A group the case grants does not invite into itself.
assert!(!files["questions/customers/index.yaml"].contains("invite:"));
let no_mail: Plan = serde_yaml::from_str(&yaml.replace(" - { name: email, type: email }\n", "").replace(" grants: { accepted: customers }\n", "")).unwrap();
assert!(no_mail.validate().unwrap_err().to_string().contains("followed_by"));
}
#[test]
fn a_document_is_a_page_with_one_card_per_section_word_for_word() {
let text = "# Vedtekter\n\n### av 23.04.2018\n\n##### § 1\n\nTomter vel er upolitisk. Foreningen har som\nformål å virke for stedets velferd.\n\n##### § 4.\n\nEkstraordinært årsmøte holdes når styret krever det.\n\nGjennomføringen følger samme prosedyre.\n";
let sections = sections_of(text);
// Titles with nothing under them are not sections; a wrapped
// paragraph is one paragraph; the words are untouched.
assert_eq!(sections.len(), 2);
assert_eq!(sections[0], Section { heading: "§ 1".into(), text: vec!["Tomter vel er upolitisk. Foreningen har som formål å virke for stedets velferd.".into()] });
assert_eq!(sections[1].heading, "§ 4");
assert_eq!(sections[1].text.len(), 2);
let mut plan: Plan = serde_yaml::from_str(&format!("{PLAN}documents:\n - id: vedtekter\n question: Hva sier vedtektene?\n description: Vedtatt 23.04.2018.\n")).unwrap();
assert!(plan.validate().unwrap_err().to_string().contains("no sections"));
plan.documents[0].sections = sections;
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
let page: Value = serde_yaml::from_str(&files["questions/vedtekter.yaml"]).unwrap();
assert_eq!(page["name"], "Hva sier vedtektene?");
let alts = page["alternatives"].as_array().unwrap();
assert_eq!(alts.len(), 2);
assert_eq!(alts[0]["name"], "§ 1");
assert_eq!(alts[0]["description"], "Tomter vel er upolitisk. Foreningen har som formål å virke for stedets velferd.");
assert!(alts[0].get("action").is_none() && alts[0].get("features").is_none());
assert_eq!(alts[1]["features"][0]["description"], "Gjennomføringen følger samme prosedyre.");
// A path something else has is refused.
plan.documents[0].id = "status".into();
assert!(plan.validate().unwrap_err().to_string().contains("document"));
}
#[test]
fn with_memberships_a_desk_invites_by_phone_and_lists_who_is_in() {
let plan: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [board]\n", "invites_by: [board]\npeople: memberships\n")).unwrap();
+9
View File
@@ -175,6 +175,15 @@ rest. Each is one line in the plan, and the builder does the wiring:
each brought in. Two is plenty; name them as a person would ("plain,
like a good notice on the door"; "warm, as a neighbour would put
it"). Leave it out to tell one story.
- **Let a person follow their own.** `followed_by: [customers]` gives
a group a desk that lists each member's own cases and nobody else's
(the ones carrying the address they sign in with), and the files on
them. With `grants` on the stage where a customer says yes, the
person who sent the case is the one who ends up able to follow it.
A `file` field takes drawings, photos, a report.
- **Carry a text word for word.** `documents` puts bylaws or terms on
a page of their own, one card per section, from a markdown file
beside the plan. Nothing rewords them.
- **Keep the people in portal.** `people: memberships` keeps every
group in portal itself: a desk invite is a place held until the
person signs in, a person is their phone number first, and any
+11 -6
View File
@@ -277,13 +277,15 @@ pub async fn attachment(
// cannot be used to learn which buckets, records or fields exist.
let missing = || (StatusCode::NOT_FOUND, "not found".to_string());
if !may_read_bucket(&state, &session, &bucket).await {
return Err(missing());
}
let store = state.jetstream.get_key_value(&bucket).await.map_err(|_| missing())?;
let bytes = store.get(&record).await.map_err(|_| missing())?.ok_or_else(missing)?;
let answer: crate::answers::Answer = serde_json::from_slice(&bytes).map_err(|_| missing())?;
let as_listed: serde_json::Value = serde_json::from_slice(&bytes).map_err(|_| missing())?;
// Through a list the caller may read, and one that shows this
// record: a list of "my cases" opens my files and nobody else's.
if !may_read_record(&state, &session, &bucket, &as_listed).await {
return Err(missing());
}
let answer: crate::answers::Answer = serde_json::from_value(as_listed).map_err(|_| missing())?;
let key = answer
.responses
.get(&field)
@@ -331,20 +333,23 @@ pub async fn attachment(
/// Whether this caller may see this bucket at all: whether any feature
/// in the loaded content lists it and the policy lets them read that
/// feature. The desk that shows the record is what grants the file.
async fn may_read_bucket(
async fn may_read_record(
state: &AppState,
session: &tower_sessions::Session,
bucket: &str,
record: &serde_json::Value,
) -> bool {
use crate::auth::{User, SESSION_USER_KEY};
let user = session.get::<User>(SESSION_USER_KEY).await.ok().flatten();
let principal = crate::access::Principal::from_user(user.as_ref());
let params = crate::resource::with_person(Default::default(), user.as_ref());
let access = state.access.load();
state.questions.load().values().any(|question| {
question.alternatives.iter().any(|alternative| {
alternative.features.iter().any(|feature| {
feature.resource.as_ref().and_then(|r| r.bucket()) == Some(bucket)
&& feature.resource.as_ref().is_some_and(|r| crate::resource::matches_filter(r, record, &params))
&& access.allows(
&principal,
crate::access::Action::Read,
+193 -11
View File
@@ -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;
@@ -269,6 +324,69 @@ main.not-found {
/* position + z-index on these three: everything that scrolls over the
sticky canvas must be a positioned box above the hero's z-index 0,
or the (positioned) hero would paint over it. */
/* Pictures across the page (`slideshow`): the full width of the
screen, every picture the strip's height and as wide as its own
shape, never wider than the screen. The caption floats over the
foot of its picture. */
.slideshow {
--swiper-navigation-color: #fff;
--swiper-navigation-size: 1.8rem;
position: relative;
z-index: 1;
display: block;
width: 100%;
height: var(--slideshow-height, 60svh);
max-height: var(--slideshow-height, 60svh);
margin: 0 0 3rem;
}
/* Before the Swiper bundle has upgraded the element, or without it:
a row that scrolls sideways. */
.slideshow:not(:defined) {
display: flex;
gap: 6px;
overflow-x: auto;
overflow-y: hidden;
}
.slideshow swiper-slide {
flex: none;
width: auto;
height: 100%;
}
.slide {
position: relative;
height: 100%;
margin: 0;
}
.slide img {
display: block;
height: 100%;
width: auto;
/* A picture that has not loaded and says nothing of its size still
takes a place, or the whole strip would sit in view at once. */
min-width: 12rem;
max-width: 100vw;
object-fit: cover;
background: var(--line);
}
.slide figcaption {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 2.4rem 1rem 0.9rem;
color: #fff;
font-size: 0.95rem;
line-height: 1.35;
text-shadow: 0 0.06rem 0.2rem rgba(0, 0, 0, 0.6);
background: linear-gradient(to top, rgba(0, 0, 0, 0.72), rgba(0, 0, 0, 0));
pointer-events: none;
}
.alternatives {
position: relative;
z-index: 1;
@@ -679,7 +797,7 @@ main.not-found {
.feature p {
color: var(--ink-dim);
font-size: 0.95rem;
font-size: 0.95em;
}
.field {
@@ -690,7 +808,7 @@ main.not-found {
}
.field-label {
font-size: 0.85rem;
font-size: 0.85em;
font-weight: 600;
color: var(--ink-dim);
}
@@ -700,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;
@@ -971,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 <a> (navigation, no POST);
@@ -1094,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;
@@ -1184,13 +1366,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;
}