Compare commits

..
Author SHA1 Message Date
Bendik Aagaard LynghaugandClaude Opus 5.5 a70690a736 Klingenberg Bygg's plan and words; a plan carries a slideshow; the words model is told what a follower's desk and an inside sent page are
Test / test (pull_request) Successful in 1m17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:56:24 +02:00
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
17 changed files with 1163 additions and 102 deletions
+9
View File
@@ -4,6 +4,15 @@ 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.
Generated
+1 -1
View File
@@ -3357,7 +3357,7 @@ dependencies = [
[[package]]
name = "portal"
version = "0.6.1"
version = "0.6.2"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "portal"
version = "0.6.1"
version = "0.6.2"
edition = "2021"
[lib]
+4
View File
@@ -263,6 +263,10 @@ A spec with neither `public: true` nor `requires_group` is
unreachable: reads fail closed, and mutations always need a group
whatever `public` says.
`where: { field: email, equals: "{user.email}" }` is "my cases": the
records carrying the signed-in person's own address, read from the
session. A file on such a record is theirs alone to open.
`states` and `where` narrow a listing. They matter most for a register
that is public by law — public about what was decided, not about what
was withdrawn — while the desk working the same bucket is a different
+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`) |
+246
View File
@@ -0,0 +1,246 @@
# Klingenberg Bygg: tilstand, prosjektering og søknad for bygg, for
# kommuner, borettslag, bedrifter og private. Bygget på det gamle
# spørreskjemaet (klingenbergbygg/kundeportal, questions.ts) og et
# intervju 2026-09-30: alle fire kundegrupper, én pult (Fredrik), og
# hele oppdraget på siden - fra henvendelse over befaring og tilbud til
# levert, med kunden innlogget for å følge saken og hente dokumentene.
# Færre felt enn det gamle skjemaet: det som må til for å ta kontakt og
# komme på befaring, resten tar vi der.
business: Klingenberg Bygg
language: "no"
contact: { name: Fredrik Borch Lynghaug, email: fredrik@klingenbergbygg.no }
mail: { from: "Klingenberg Bygg <portal@klingenbergbygg.no>", reply_to: fredrik@klingenbergbygg.no }
about: >
Med vår erfaring får vi kartlagt **tilstand**, utarbeidet **prosjekt**
og godkjent **søknad** for ditt bygg.
# Kontoret er pulten; kundene får konto i det de takker ja til et
# tilbud, og ser da sine egne oppdrag og dokumentene vi leverer.
groups:
- { id: kontor, label: Kontoret }
- { id: kunder, label: Kunder }
invites_by: [kontor]
# Forsiden åpner med det vi gjør, uten knapp: to trekk.
does:
- id: tilstand
for: tilstand
text: >
Vi utarbeider tilstandsrapporter etter NS-EN 3424 og NS 3423, med
befaring, materialprøver og termofotografering. Skal bygget rives,
leverer vi miljøsaneringsrapport; energibruk og besparelser hører
med i vurderingen. Du får et tilbud og sier selv ja eller nei.
- id: soknad
for: soknad
text: >
Vi prosjekterer og søker: arbeidsbeskrivelse opp mot NS 3420,
fremdriftsplan, og byggesøknad med ansvarsrett. Vi har sentral
godkjenning innen flere fagområder. Når tilbudet er godtatt,
følger du oppdraget og henter dokumentene innlogget.
needs:
- id: tilstand
who: en eier eller forvalter som må vite hvilken forfatning bygget er i, for en kommune, et borettslag, en bedrift eller seg selv
wants: en tilstandsrapport å planlegge og beslutte ut fra
lands_in: tilstandsrapporter
handled_by: kontor
followed_by: [kunder]
stages: [open, befaring, tilbud, akseptert, i_arbeid, levert, avslatt]
stages_text: { open: "mottatt", befaring: "befaring avtalt", tilbud: "tilbud sendt", akseptert: "tilbud godtatt", i_arbeid: "i arbeid", levert: "levert", avslatt: "avsluttet uten oppdrag" }
done_when: [levert, avslatt]
success: [levert]
turned_down_from: [open, befaring, tilbud]
# Kunden sier selv ja eller nei til tilbudet, gjennom sin egen lenke.
moves_by: { akseptert: [submitter], avslatt: [submitter, kontor] }
mails: { open: submitter, befaring: submitter, tilbud: submitter, akseptert: fredrik@klingenbergbygg.no, levert: submitter }
# Et godtatt tilbud er kundeforholdet: konto og plass blant kundene.
grants: { akseptert: kunder }
max_fields: 4
collects:
- { name: name }
- { name: email, type: email }
- { name: telefon, type: tel, optional: true }
- { name: eiendom, type: select, options: [Skole eller annet offentlig bygg, Borettslag eller sameie, Næringsbygg, Enebolig, Leilighet, Fritidsbolig, Annet] }
- { name: adresse }
- { name: mangler, type: textarea, optional: true }
- { name: tegninger, type: file, optional: true }
- id: prosjekt
who: en byggherre som har planer for bygget og trenger dem prosjektert før noen kan bygge
wants: en arbeidsbeskrivelse og fremdriftsplan som gir det beste grunnlaget for utførelse
lands_in: prosjekteringer
handled_by: kontor
followed_by: [kunder]
stages: [open, befaring, tilbud, akseptert, i_arbeid, levert, avslatt]
stages_text: { open: "mottatt", befaring: "befaring avtalt", tilbud: "tilbud sendt", akseptert: "tilbud godtatt", i_arbeid: "i arbeid", levert: "levert", avslatt: "avsluttet uten oppdrag" }
done_when: [levert, avslatt]
success: [levert]
turned_down_from: [open, befaring, tilbud]
moves_by: { akseptert: [submitter], avslatt: [submitter, kontor] }
mails: { open: submitter, befaring: submitter, tilbud: submitter, akseptert: fredrik@klingenbergbygg.no, levert: submitter }
grants: { akseptert: kunder }
max_fields: 4
collects:
- { name: name }
- { name: email, type: email }
- { name: telefon, type: tel, optional: true }
- { name: adresse }
- { name: planer, type: textarea }
- { name: oppstart, type: date, optional: true }
- { name: skisser, type: file, optional: true }
- id: soknad
who: en byggherre med et ferdig prosjektert bygg som trenger offentlig godkjenning før første spadetak
wants: en godkjent byggesøknad, med ansvarsrett der det kreves
lands_in: soknader
handled_by: kontor
followed_by: [kunder]
stages: [open, befaring, tilbud, akseptert, i_arbeid, levert, avslatt]
stages_text: { open: "mottatt", befaring: "forhåndskonferanse eller befaring avtalt", tilbud: "tilbud sendt", akseptert: "tilbud godtatt", i_arbeid: "søknad under arbeid", levert: "søknad levert", avslatt: "avsluttet uten oppdrag" }
done_when: [levert, avslatt]
success: [levert]
turned_down_from: [open, befaring, tilbud]
moves_by: { akseptert: [submitter], avslatt: [submitter, kontor] }
mails: { open: submitter, befaring: submitter, tilbud: submitter, akseptert: fredrik@klingenbergbygg.no, levert: submitter }
grants: { akseptert: kunder }
max_fields: 4
collects:
- { name: name }
- { name: email, type: email }
- { name: telefon, type: tel, optional: true }
- { name: adresse }
- { name: tiltak, type: textarea }
- { name: tegninger, type: file, optional: true }
# Det kontoret leverer: rapporten, tegningene, vedtaket. Kunden får
# e-post og henter dokumentet innlogget, fra sin egen liste.
- id: leveranse
who: kontoret, når en rapport, tegninger eller et vedtak er klart til kunden
wants: at kunden får dokumentet og vet at det ligger der
enters_as: kontor
lands_in: leveranser
handled_by: kontor
followed_by: [kunder]
stages: [open, arkivert]
stages_text: { open: "levert", arkivert: "arkivert" }
done_when: [arkivert]
success: [arkivert]
mails: { open: submitter }
max_fields: 3
collects:
- { name: email, type: email }
- { name: hva }
- { name: dokument, type: file }
personas:
- { id: kommune-skole, need: tilstand, text: "Vi i kommunen skal legge fram en sak om rehabilitering av en barneskole fra 1972 og trenger en tilstandsanalyse politikerne kan beslutte ut fra." }
- { id: borettslag, need: tilstand, text: "Styret i borettslaget lurer på hva som haster mest av tak, fasade og rør. Vi trenger noen som kan gå over byggene og si hva det står til." }
- { id: sprekk, need: tilstand, text: "Det har kommet en sprekk midt på stueveggen og jeg vet ikke om det er farlig. Kan noen komme og se på det?" }
- { id: tilbygg, need: prosjekt, text: "Vi vil bygge på et tilbygg på lageret vårt og trenger noen som kan beskrive arbeidet så vi kan hente inn priser fra entreprenører." }
- { id: skole-i-drift, need: prosjekt, text: "Skolen skal utvides mens den er i drift. Vi trenger en beskrivelse og en fremdriftsplan med byggetrinn." }
- { id: soke-garasje, need: soknad, text: "Tegningene til garasjen er ferdige, men jeg aner ikke hvordan jeg søker kommunen. Kan dere ta søknaden?" }
- { id: ansvarsrett, need: soknad, text: "Vi har prosjektert ombyggingen selv, men mangler noen med ansvarsrett som kan stå for søknaden." }
slideshow:
max_height: 60
slides:
- image: images/bildeserie/01.webp
width: 1867
height: 1400
caption: Tilstandsanalyse for politisk vurdering.
- image: images/bildeserie/02.webp
width: 2365
height: 1400
caption: Tilleggsisolert fasade uten fasadeendring. Tanum skole.
- image: images/bildeserie/03.webp
width: 1867
height: 1400
caption: Fundamenteringen av brannstasjonen i Ytre Enebakk
- image: images/bildeserie/04.webp
width: 1867
height: 1400
caption: Gammel stjernehimmel skal fornyes
- image: images/bildeserie/05.webp
width: 1050
height: 1400
caption: Sprekk midt på stuevegg
- image: images/bildeserie/06.webp
width: 1867
height: 1400
caption: Snart klart for overlevering! Løvenstad omsorgsboliger.
- image: images/bildeserie/07.webp
width: 1867
height: 1400
caption: Omlegging av vannforsyning før etablering av nye boliger
- image: images/bildeserie/08.webp
width: 1777
height: 1400
caption: Utbedring parkett i gymsal, sliping og lakking.
- image: images/bildeserie/09.webp
width: 3629
height: 1400
caption: Tilbygg på skole i drift, i Ås
- image: images/bildeserie/10.webp
width: 3207
height: 1400
caption: Fornying kantine Det Norske Teatret
- image: images/bildeserie/11.webp
width: 1050
height: 1400
caption: Solid veggprofil tilpasset lyd og brann-krav
- image: images/bildeserie/12.webp
width: 1867
height: 1400
caption: Nye vinduer med tradisjonelt-utseende «Restover» glass som avslører utsikten fra Munkeramme på Hvitsten
- image: images/bildeserie/13.webp
width: 2571
height: 1400
caption: Kjærneved til varanda og inngangspartiet til Muncheramme
- image: images/bildeserie/14.webp
width: 1050
height: 1400
caption: Drenering og riktige rammer før kunsgressbanen legges mesterfullt ned helt uten gummikuler
- image: images/bildeserie/15.webp
width: 2098
height: 1400
caption: Navnebror sprenger plass til ny brannstasjon
- image: images/bildeserie/16.webp
width: 1283
height: 1400
caption: Kontroll av betong før støping
- image: images/bildeserie/17.webp
width: 1050
height: 1400
caption: Etanblering av ny byggekran
- image: images/bildeserie/18.webp
width: 1050
height: 1400
caption: Armerignskontroll før støping
- image: images/bildeserie/19.webp
width: 1867
height: 1400
caption: Brannstasjonens tårn under oppføring blir også klatrevegg
- image: images/bildeserie/20.webp
width: 1867
height: 1400
caption: Her heises Moelvens modulskole i vestby på plass. Den blir stående i 10 år.
- image: images/bildeserie/21.webp
width: 1867
height: 1400
caption: Nyetablering av vei.
- image: images/bildeserie/22.webp
width: 838
height: 1400
caption: Støpefeil med frosset treverk i søyle fra 1960, avdekket og utbedret
- image: images/bildeserie/23.webp
width: 2095
height: 1400
caption: Nytt kunstgress med varme på Fredikstad stasion.
- image: images/bildeserie/24.webp
width: 1050
height: 1400
caption: Sydvendte fasader er utsatt for slagregn. Dette bygget er på byantikvarens gule liste.
- image: images/bildeserie/25.webp
width: 1487
height: 1400
caption: pH-test av borreprøver i mur fra forskjellige dybder
+441
View File
@@ -0,0 +1,441 @@
{
"questions/index.yaml": {
"question": "Hvordan inkluderer vi ditt bygg i framtiden?",
"description": "Med vår erfaring får vi kartlagt **tilstand**, utarbeidet **prosjekt** og godkjent **søknad** for ditt bygg.",
"opener": {
"heading": "Dette gjør vi",
"description": "For kommuner, borettslag, bedrifter og private.",
"features": {
"tilstand": {
"heading": "Tilstand, målt og dokumentert",
"description": "Vi utarbeider tilstandsrapporter etter NS-EN 3424 og NS 3423, med befaring, materialprøver og termofotografering. Skal bygget rives, leverer vi miljøsaneringsrapport; energibruk og besparelser hører med i vurderingen. Vi har samarbeidet med [Drammen Kommune](https://drammen.kommune.no), [Nordre Follo Kommune](https://nordrefollo.kommune.no) og [Bølerskogens Borettslag](https://bolerskogenborettslag.no/).",
"icon": "clipboard-check"
},
"soknad": {
"heading": "Prosjekt og søknad, med ansvarsrett",
"description": "Vi lager arbeidsbeskrivelse opp mot [NS 3420](https://standard.no/fagomrader/ns-3420-/) og fremdriftsplan, og utarbeider byggesøknaden. Vi har sentral [godkjenning](https://sgregister.dibk.no/enterprises/976015711) innen flere fagområder og kan stå med det offentligrettslige ansvaret. Samarbeidspartner: [Westra Engineering AS](https://www.westra-engineering.no/).",
"icon": "file-check"
}
}
},
"needs": {
"tilstand": {
"heading": "Få kartlagt byggets tilstand",
"description": "Vi utarbeider tilstandsrapporter som representerer bygget med hensyn til fremtidige mål.",
"button": "Kom i gang",
"encouragement": "Mindre stillstand etter dette.",
"icon": "clipboard-list",
"road_title": "Slik går det videre",
"road_description": "Vi avtaler befaring og sender deg et tilbud. Du sier selv ja eller nei, og med et ja får du en innlogging der du følger oppdraget og henter dokumentene.",
"road_icon": "route",
"who_title": "Hvem leser dette",
"who_description": "Fredrik på kontoret leser henvendelsen og tar kontakt. Du når ham også på fredrik@klingenbergbygg.no.",
"who_icon": "users",
"form_title": "Om bygget",
"form_description": "Det vi trenger for å komme på befaring. Har du tegninger, bilder eller video, tar vi gjerne imot dem.",
"labels": {
"name": "Navn",
"email": "E-post",
"telefon": "Telefon",
"adresse": "Hvor ligger eiendommen?",
"eiendom": "Hva slags eiendom er det?",
"mangler": "Hvilke mangler ønskes adressert?",
"tegninger": "Bilder, tegninger, skisser eller video"
},
"hints": {
"name": "Navn",
"email": "bruker@eksempel.no",
"telefon": "Telefonnummer",
"adresse": "Adresse",
"mangler": "Forfall, skader eller annet…"
}
},
"prosjekt": {
"heading": "Prosjektér dine planer med oss",
"description": "Ivareta eksisterende ressurser for et velfungerende og helhetlig bygg.",
"button": "Start samarbeidet",
"encouragement": "Gi det beste grunnlaget for utførelse.",
"icon": "ruler",
"road_title": "Slik går det videre",
"road_description": "Vi avtaler befaring og sender deg et tilbud. Du sier selv ja eller nei, og med et ja får du en innlogging der du følger oppdraget og henter dokumentene.",
"road_icon": "route",
"who_title": "Hvem leser dette",
"who_description": "Fredrik på kontoret leser henvendelsen og tar kontakt. Du når ham også på fredrik@klingenbergbygg.no.",
"who_icon": "users",
"form_title": "Om planene",
"form_description": "Beskriv hva du ønsker for bygget. Har du noe digitalt å vise oss, blir det lettere å vite hvor vi skal starte.",
"labels": {
"name": "Navn",
"email": "E-post",
"telefon": "Telefon",
"adresse": "Hvor ligger eiendommen?",
"planer": "Hva er dine planer for bygget?",
"oppstart": "Ønsket start",
"skisser": "Bilder, CAD-filer og skisser"
},
"hints": {
"name": "Navn",
"email": "bruker@eksempel.no",
"telefon": "Telefonnummer",
"adresse": "Adresse",
"planer": "Ønsker for bygget…"
}
},
"soknad": {
"heading": "Ta det første spadetaket med en godkjent søknad",
"description": "Er bygget ferdig prosjektert, kan vi utarbeide en byggesøknad og utførelsesbeskrivelse for offentlig godkjenning.",
"button": "Send henvendelse",
"encouragement": "Nesten som å ha gravemaskin!",
"icon": "file-text",
"road_title": "Slik går det videre",
"road_description": "Vi går gjennom det du har, avtaler befaring eller forhåndskonferanse, og sender deg et tilbud. Du sier selv ja eller nei, og med et ja får du en innlogging der du følger søknaden og henter dokumentene.",
"road_icon": "route",
"who_title": "Hvem leser dette",
"who_description": "Fredrik på kontoret leser henvendelsen og tar kontakt. Du når ham også på fredrik@klingenbergbygg.no.",
"who_icon": "users",
"form_title": "Om tiltaket",
"form_description": "Hva skal bygges, og hvor. Avhengig av størrelsen kan vi holde forhåndskonferanse med kommunen først.",
"labels": {
"name": "Navn",
"email": "E-post",
"telefon": "Telefon",
"adresse": "Hvor ligger eiendommen?",
"tiltak": "Hva går tiltaket ut på?",
"tegninger": "Tegninger og prosjektering"
},
"hints": {
"name": "Navn",
"email": "bruker@eksempel.no",
"telefon": "Telefonnummer",
"adresse": "Adresse",
"tiltak": "Tilbygg, ombygging, nybygg…"
}
}
}
},
"questions/received/tilstand.yaml": {
"question": "Vent på svar på din henvendelse!",
"description": "Vi vil gå gjennom din henvendelse og om mulig komme med et tilbud så fort vi kan.",
"next_heading": "Vi tar kontakt for befaring",
"next_description": "Du har fått en e-post som bekrefter at henvendelsen om tilstandsrapport er mottatt. Fredrik tar kontakt for å avtale befaring; spørsmål i mellomtiden går til fredrik@klingenbergbygg.no."
},
"questions/received/prosjekt.yaml": {
"question": "Vent på svar på din henvendelse!",
"description": "Vi vil gå gjennom din henvendelse og om mulig komme med et tilbud så fort vi kan.",
"next_heading": "Vi tar kontakt for befaring",
"next_description": "Du har fått en e-post som bekrefter at henvendelsen om prosjektering er mottatt. Fredrik tar kontakt for å avtale befaring; spørsmål i mellomtiden går til fredrik@klingenbergbygg.no."
},
"questions/received/soknad.yaml": {
"question": "Vent på svar på din henvendelse!",
"description": "Vi vil gå gjennom din henvendelse og om mulig komme med et tilbud så fort vi kan.",
"next_heading": "Vi tar kontakt for befaring",
"next_description": "Du har fått en e-post som bekrefter at henvendelsen om byggesøknad er mottatt. Fredrik tar kontakt for å avtale befaring; spørsmål i mellomtiden går til fredrik@klingenbergbygg.no."
},
"questions/decide/tilstand.yaml": {
"question": "Vil du gå videre med tilbudet?",
"description": "Tilbudet på tilstandsrapporten har du fått i egen e-post fra oss. Her sier du ja eller nei.",
"moves": {
"akseptert": {
"heading": "Ja, jeg godtar tilbudet",
"description": "Vi setter i gang. Du får en innlogging på e-post, der du følger oppdraget og henter dokumentene når de er klare.",
"button": "Godta tilbudet"
},
"avslatt": {
"heading": "Nei takk",
"description": "Henvendelsen avsluttes hos oss uten oppdrag. Du kan når som helst sende en ny.",
"button": "Takk nei"
}
}
},
"questions/decide/prosjekt.yaml": {
"question": "Vil du gå videre med tilbudet?",
"description": "Tilbudet på prosjekteringen har du fått i egen e-post fra oss. Her sier du ja eller nei.",
"moves": {
"akseptert": {
"heading": "Ja, jeg godtar tilbudet",
"description": "Vi setter i gang. Du får en innlogging på e-post, der du følger oppdraget og henter dokumentene når de er klare.",
"button": "Godta tilbudet"
},
"avslatt": {
"heading": "Nei takk",
"description": "Henvendelsen avsluttes hos oss uten oppdrag. Du kan når som helst sende en ny.",
"button": "Takk nei"
}
}
},
"questions/decide/soknad.yaml": {
"question": "Vil du gå videre med tilbudet?",
"description": "Tilbudet på byggesøknaden har du fått i egen e-post fra oss. Her sier du ja eller nei.",
"moves": {
"akseptert": {
"heading": "Ja, jeg godtar tilbudet",
"description": "Vi setter i gang. Du får en innlogging på e-post, der du følger oppdraget og henter dokumentene når de er klare.",
"button": "Godta tilbudet"
},
"avslatt": {
"heading": "Nei takk",
"description": "Henvendelsen avsluttes hos oss uten oppdrag. Du kan når som helst sende en ny.",
"button": "Takk nei"
}
}
},
"questions/kontor/index.yaml": {
"question": "Hva ligger på pulten nå?",
"description": "Alle henvendelser og oppdrag, fra første melding til levert dokument.",
"confirm": "Bekreft",
"buckets": {
"tilstandsrapporter": {
"heading": "Tilstandsrapporter",
"description": "Henvendelser om tilstandsrapport, fra mottatt til levert.",
"moves": {
"open->befaring": "Befaring avtalt",
"open->avslatt": "Avslutt uten oppdrag",
"befaring->tilbud": "Tilbud sendt",
"befaring->avslatt": "Avslutt uten oppdrag",
"tilbud->avslatt": "Avslutt uten oppdrag",
"akseptert->i_arbeid": "Arbeidet er i gang",
"i_arbeid->levert": "Levert"
},
"list_title": "Tilstandsrapporter",
"list_note": "Hver rad er én henvendelse. Knappene fører opp det du nettopp har gjort; kunden får e-post ved befaring, tilbud og levering, og sier selv ja til tilbudet."
},
"prosjekteringer": {
"heading": "Prosjekteringer",
"description": "Henvendelser om prosjektering, fra mottatt til levert.",
"moves": {
"open->befaring": "Befaring avtalt",
"open->avslatt": "Avslutt uten oppdrag",
"befaring->tilbud": "Tilbud sendt",
"befaring->avslatt": "Avslutt uten oppdrag",
"tilbud->avslatt": "Avslutt uten oppdrag",
"akseptert->i_arbeid": "Arbeidet er i gang",
"i_arbeid->levert": "Levert"
},
"list_title": "Prosjekteringer",
"list_note": "Hver rad er én henvendelse. Knappene fører opp det du nettopp har gjort; kunden får e-post ved befaring, tilbud og levering, og sier selv ja til tilbudet."
},
"soknader": {
"heading": "Byggesøknader",
"description": "Henvendelser om byggesøknad, fra mottatt til levert.",
"moves": {
"open->befaring": "Befaring avtalt",
"open->avslatt": "Avslutt uten oppdrag",
"befaring->tilbud": "Tilbud sendt",
"befaring->avslatt": "Avslutt uten oppdrag",
"tilbud->avslatt": "Avslutt uten oppdrag",
"akseptert->i_arbeid": "Arbeidet er i gang",
"i_arbeid->levert": "Levert"
},
"list_title": "Byggesøknader",
"list_note": "Hver rad er én henvendelse. Knappene fører opp det du nettopp har gjort; kunden får e-post ved befaring, tilbud og levering, og sier selv ja til tilbudet."
},
"leveranser": {
"heading": "Leverte dokumenter",
"description": "Rapporter, tegninger og vedtak som er sendt til kunder.",
"moves": {
"open->arkivert": "Arkiver"
},
"list_title": "Leveranser",
"list_note": "Hver rad er ett dokument en kunde har fått. Arkiver når saken er avsluttet."
}
},
"links": {
"leveranse": {
"heading": "Lever et dokument til en kunde",
"description": "Rapporten, tegningene eller vedtaket. Kunden får e-post og henter det innlogget.",
"button": "Ny leveranse"
}
},
"invites": {
"kontor": {
"heading": "Gi en kollega tilgang",
"description": "De får en konto og plass på kontoret, og ser de samme sakene som deg.",
"button": "Inviter",
"form_title": "Hvem",
"form_description": "Navnet deres, og adressen kontoen og engangslenken går til.",
"labels": {
"name": "Navn",
"phone": "Telefon",
"email": "E-post"
}
},
"kunder": {
"heading": "Gi en kunde tilgang",
"description": "For en kunde som ikke kom inn gjennom et tilbud. De får en konto og ser sine egne oppdrag.",
"button": "Inviter",
"form_title": "Hvem",
"form_description": "Navnet deres, og adressen kontoen og engangslenken går til.",
"labels": {
"name": "Navn",
"phone": "Telefon",
"email": "E-post"
}
}
},
"invited": {
"heading": "Sendte invitasjoner",
"description": "Hver invitasjon og hvor den står. En lenke som går ut ubrukt, kan sendes på nytt.",
"done": "Er inne",
"again": "Send på nytt",
"list_title": "Hvem som er invitert",
"list_note": "Hver rad er én invitasjon. Den blir ferdig av seg selv når personen logger inn, og står som utløpt når lenken gikk ut ubrukt."
},
"suggestions": {
"heading": "Forslag til endringer på siden",
"description": "Det de som står som ansvarlige for et spørsmål, har foreslått å endre.",
"list_title": "Forslag",
"list_note": "Hver rad er ett forslag, med siden det gjelder.",
"looking": "Vi ser på det",
"changed": "Endringen er gjort",
"declined": "Gjøres ikke"
}
},
"questions/kunder/index.yaml": {
"question": "Hvor står oppdraget ditt?",
"description": "Her følger du dine egne oppdrag hos Klingenberg Bygg og henter dokumentene vi har levert.",
"confirm": "Bekreft",
"buckets": {
"leveranser": {
"heading": "Dokumenter til deg",
"description": "Rapporter, tegninger og vedtak vi har levert. Trykk på filnavnet for å hente dokumentet.",
"moves": {},
"list_title": "Dokumenter",
"list_note": "Hver rad er ett dokument vi har levert til deg."
},
"tilstandsrapporter": {
"heading": "Tilstandsrapporter",
"description": "Dine oppdrag om tilstandsrapport, og hvor langt de har kommet.",
"moves": {},
"list_title": "Tilstandsrapporter",
"list_note": "Hver rad er ett av dine egne oppdrag og hvor langt det har kommet."
},
"prosjekteringer": {
"heading": "Prosjekteringer",
"description": "Dine oppdrag om prosjektering, og hvor langt de har kommet.",
"moves": {},
"list_title": "Prosjekteringer",
"list_note": "Hver rad er ett av dine egne oppdrag og hvor langt det har kommet."
},
"soknader": {
"heading": "Byggesøknader",
"description": "Dine oppdrag om byggesøknad, og hvor langt de har kommet.",
"moves": {},
"list_title": "Byggesøknader",
"list_note": "Hver rad er ett av dine egne oppdrag og hvor langt det har kommet."
}
}
},
"questions/kontor/leveranse.yaml": {
"heading": "Lever et dokument til en kunde",
"description": "Last opp rapporten, tegningene eller vedtaket. Kunden får e-post og henter dokumentet innlogget.",
"button": "Lever",
"encouragement": "Kunden får beskjed med en gang.",
"icon": "send",
"road_title": "Hva skjer",
"road_description": "Dokumentet legges på kundens side, og kunden får e-post om at det ligger der.",
"road_icon": "route",
"who_title": "Hvem ser dette",
"who_description": "Kunden med den e-postadressen du oppgir, og kontoret.",
"who_icon": "users",
"form_title": "Leveransen",
"form_description": "Bruk den e-postadressen kunden logger inn med.",
"labels": {
"email": "Kundens e-post",
"hva": "Hva er levert",
"dokument": "Dokument"
},
"hints": {
"email": "kunde@eksempel.no",
"hva": "Tilstandsrapport for Storgata 12",
"dokument": ""
},
"question": "Hvilket dokument er klart til kunden?",
"page_description": "Ferdige rapporter, tegninger og vedtak leveres her."
},
"questions/kontor/leveranse-sent.yaml": {
"question": "Hva skjer nå?",
"description": "Dokumentet er levert.",
"next_heading": "Kunden har fått beskjed",
"next_description": "Kunden har fått e-post og finner dokumentet på sin side. Leveransen ligger i listen på pulten til du arkiverer den."
},
"aggregates.yaml": {
"tilstandsrapporter": {
"open": {
"subject": "Vi har mottatt henvendelsen om {adresse}",
"body": "Hei {name},\n\nVi har mottatt henvendelsen din om tilstandsrapport for {adresse}. Fredrik tar kontakt for å avtale befaring.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/tilstand?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"befaring": {
"subject": "Befaring avtalt for {adresse}",
"body": "Hei {name},\n\nVi har avtalt befaring for {adresse}. Etter befaringen sender vi deg et tilbud.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/tilstand?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"tilbud": {
"subject": "Tilbud på tilstandsrapport for {adresse}",
"body": "Hei {name},\n\nTilbudet på tilstandsrapport for {adresse} er sendt deg i egen e-post. Si ja eller nei her:\n{site}/decide/tilstand?chain={chain}&email={email}\n\nMed et ja får du en innlogging der du følger oppdraget og henter dokumentene. Svar på denne e-posten om du lurer på noe.\n\nKlingenberg Bygg"
},
"akseptert": {
"subject": "Tilbudet for {adresse} er godtatt",
"body": "{name} ({email}) har godtatt tilbudet på tilstandsrapport for {adresse}.\n\nOppdraget ligger på pulten: {site}/kontor"
},
"levert": {
"subject": "Tilstandsrapport for {adresse} er levert",
"body": "Hei {name},\n\nTilstandsrapport for {adresse} er levert. Dokumentene henter du innlogget:\n{site}/kunder\n\nTakk for oppdraget.\n\nKlingenberg Bygg"
}
},
"prosjekteringer": {
"open": {
"subject": "Vi har mottatt henvendelsen om {adresse}",
"body": "Hei {name},\n\nVi har mottatt henvendelsen din om prosjektering for {adresse}. Fredrik tar kontakt for å avtale befaring.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/prosjekt?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"befaring": {
"subject": "Befaring avtalt for {adresse}",
"body": "Hei {name},\n\nVi har avtalt befaring for {adresse}. Etter befaringen sender vi deg et tilbud.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/prosjekt?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"tilbud": {
"subject": "Tilbud på prosjektering for {adresse}",
"body": "Hei {name},\n\nTilbudet på prosjektering for {adresse} er sendt deg i egen e-post. Si ja eller nei her:\n{site}/decide/prosjekt?chain={chain}&email={email}\n\nMed et ja får du en innlogging der du følger oppdraget og henter dokumentene. Svar på denne e-posten om du lurer på noe.\n\nKlingenberg Bygg"
},
"akseptert": {
"subject": "Tilbudet for {adresse} er godtatt",
"body": "{name} ({email}) har godtatt tilbudet på prosjektering for {adresse}.\n\nOppdraget ligger på pulten: {site}/kontor"
},
"levert": {
"subject": "Prosjektering for {adresse} er levert",
"body": "Hei {name},\n\nProsjektering for {adresse} er levert. Dokumentene henter du innlogget:\n{site}/kunder\n\nTakk for oppdraget.\n\nKlingenberg Bygg"
}
},
"soknader": {
"open": {
"subject": "Vi har mottatt henvendelsen om {adresse}",
"body": "Hei {name},\n\nVi har mottatt henvendelsen din om byggesøknad for {adresse}. Fredrik tar kontakt for å avtale befaring.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/soknad?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"befaring": {
"subject": "Befaring avtalt for {adresse}",
"body": "Hei {name},\n\nVi har avtalt befaring for {adresse}. Etter befaringen sender vi deg et tilbud.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/soknad?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"tilbud": {
"subject": "Tilbud på byggesøknad for {adresse}",
"body": "Hei {name},\n\nTilbudet på byggesøknad for {adresse} er sendt deg i egen e-post. Si ja eller nei her:\n{site}/decide/soknad?chain={chain}&email={email}\n\nMed et ja får du en innlogging der du følger oppdraget og henter dokumentene. Svar på denne e-posten om du lurer på noe.\n\nKlingenberg Bygg"
},
"akseptert": {
"subject": "Tilbudet for {adresse} er godtatt",
"body": "{name} ({email}) har godtatt tilbudet på byggesøknad for {adresse}.\n\nOppdraget ligger på pulten: {site}/kontor"
},
"levert": {
"subject": "Byggesøknad for {adresse} er levert",
"body": "Hei {name},\n\nByggesøknad for {adresse} er levert. Dokumentene henter du innlogget:\n{site}/kunder\n\nTakk for oppdraget.\n\nKlingenberg Bygg"
}
},
"leveranser": {
"open": {
"subject": "{hva} ligger klar til deg",
"body": "Hei,\n\n{hva} er levert fra Klingenberg Bygg. Logg inn og hent dokumentet her:\n{site}/kunder\n\nSvar på denne e-posten om noe mangler.\n\nKlingenberg Bygg"
}
}
},
"site.yaml": {
"invite": {
"subject": "Din innlogging hos {site_name}",
"body": "Hei {name},\n\nDu har fått en konto hos {site_name}, med plass blant {group}. Velg passord eller passnøkkel her, lenken virker fram til {expires}:\n\n{link}\n\nBrukernavnet ditt er {username}. Etterpå logger du inn på {site}."
}
}
}
+138 -59
View File
@@ -396,61 +396,9 @@ fn QuestionView(
landing=question_id == "/"
site=site.clone()
current_id=question_id.clone()
user=user.clone()
slideshow=question.slideshow.clone()
/>
// Pictures across the page: the full width of the screen, each
// as wide as its own shape at the strip's height, the caption
// laid over its foot. Inert until the Swiper bundle upgrades
// the elements; without it, a row that scrolls sideways.
{question
.slideshow
.clone()
.map(|show| {
let height = format!("--slideshow-height: {}svh", show.max_height);
view! {
// In the page itself, so the strip works from the
// server's HTML alone; the head's copy above covers
// arriving here by a link within the site.
<script src="/swiper-element-bundle.min.js" defer=true></script>
<swiper-container
class="slideshow"
style=height
slides-per-view="auto"
space-between="6"
grab-cursor="true"
free-mode="true"
mousewheel-force-to-axis="true"
keyboard="true"
navigation="true"
>
<For
each={move || show.slides.clone().into_iter().enumerate().collect::<Vec<_>>()}
key=|(_, slide)| slide.image.clone()
children=|(n, slide): (usize, crate::content::Slide)| {
let caption = slide.caption.clone();
// The first screenful loads with the
// page; the rest when swiped to.
let loading = if n < 4 { "eager" } else { "lazy" };
view! {
<swiper-slide>
<figure class="slide">
<img
src=slide.src()
alt=slide.caption.clone()
width=slide.width.map(|w| w.to_string())
height=slide.height.map(|h| h.to_string())
loading=loading
decoding="async"
/>
{(!caption.trim().is_empty()).then(|| view! { <figcaption>{caption}</figcaption> })}
</figure>
</swiper-slide>
}
.into_any()
}
/>
</swiper-container>
}
})}
// A page that is about somewhere says where, under the
// question: a still from this origin, and the place in words
// for anyone who cannot see it.
@@ -895,7 +843,15 @@ mod gesture {
}
#[component]
fn Hero(title: String, description: String, landing: bool, site: SiteConfig, current_id: String) -> impl IntoView {
fn Hero(
title: String,
description: String,
landing: bool,
site: SiteConfig,
current_id: String,
#[prop(optional_no_strip)] user: Option<User>,
#[prop(optional_no_strip)] slideshow: Option<crate::content::Slideshow>,
) -> impl IntoView {
// Only the landing page gets a piece - it's the one page a hero is
// actually "the" hero for; every other page gets the plain header.
// WHAT the piece is belongs to the content repo: site.yaml's
@@ -994,9 +950,37 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur
)
});
// Who is here, and the way in or out, at the top of every page of
// a site that has sign-in at all.
let session = site.sign_in.then(|| {
let redirect = current_id.clone();
match user {
Some(user) => view! {
<nav class="session">
<span class="session-name">{user.display_name}</span>
<a href="/auth/logout" rel="external">{move || t(&lang().get(), "sign_out")}</a>
</nav>
}
.into_any(),
None => view! {
<nav class="session">
<a href=format!("/auth/login?redirect={redirect}") rel="external">{move || t(&lang().get(), "sign_in")}</a>
</nav>
}
.into_any(),
}
});
let has_slides = slideshow.is_some();
let mark = move || view! {
<a class="wordmark" class:wordmark-house=house_wordmark href="/">
<img src=wordmark.clone() alt=site_title.clone() style=wordmark_style.clone()/>
</a>
};
view! {
<header class="hero" class:hero-module=has_module>
<header class="hero" class:hero-module=has_module class:hero-slides=has_slides>
<Announcements current_id=current_id/>
{session}
{has_module
.then(|| {
view! {
@@ -1004,10 +988,13 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur
<script type="module" inner_html=early_script.unwrap_or_default()></script>
}
})}
// With pictures, the wordmark is a bar of its own above
// them and the question comes under; without, the wordmark
// heads the copy as it always has.
{has_slides.then(|| view! { <div class="hero-bar">{mark()}</div> })}
{slideshow.map(|show| view! { <SlideStrip show=show/> })}
<div class="hero-copy">
<a class="wordmark" class:wordmark-house=house_wordmark href="/">
<img src=wordmark alt=site_title style=wordmark_style/>
</a>
{(!has_slides).then(|| mark())}
<h1>{title}</h1>
<p inner_html=render_inline_markdown(&description)></p>
</div>
@@ -1015,6 +1002,59 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur
}
}
/// Pictures across the page (`slideshow`): the full width of the
/// screen, each picture as wide as its own shape at the strip's height,
/// the caption laid over its foot. Inert until the Swiper bundle
/// upgrades the elements; without it, a row that scrolls sideways.
#[component]
fn SlideStrip(show: crate::content::Slideshow) -> impl IntoView {
let height = format!("--slideshow-height: {}svh", show.max_height);
view! {
// In the page itself, so the strip works from the
// server's HTML alone; the head's copy above covers
// arriving here by a link within the site.
<script src="/swiper-element-bundle.min.js" defer=true></script>
<swiper-container
class="slideshow"
style=height
slides-per-view="auto"
space-between="6"
grab-cursor="true"
free-mode="true"
mousewheel-force-to-axis="true"
keyboard="true"
navigation="true"
>
<For
each={move || show.slides.clone().into_iter().enumerate().collect::<Vec<_>>()}
key=|(_, slide)| slide.image.clone()
children=|(n, slide): (usize, crate::content::Slide)| {
let caption = slide.caption.clone();
// The first screenful loads with the
// page; the rest when swiped to.
let loading = if n < 4 { "eager" } else { "lazy" };
view! {
<swiper-slide>
<figure class="slide">
<img
src=slide.src()
alt=slide.caption.clone()
width=slide.width.map(|w| w.to_string())
height=slide.height.map(|h| h.to_string())
loading=loading
decoding="async"
/>
{(!caption.trim().is_empty()).then(|| view! { <figcaption>{caption}</figcaption> })}
</figure>
</swiper-slide>
}
.into_any()
}
/>
</swiper-container>
}
}
/// An alternative's banner imagery: nothing, a plain image, or - for
/// several urls - a Swiper Element card deck. The `swiper-container`/
/// `swiper-slide` custom elements render inert server-side and upgrade
@@ -1360,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
@@ -1813,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>,
@@ -1848,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 {
@@ -1857,6 +1900,7 @@ fn ResourceFeature(
feature_name=feature_name
transitions=transitions
link=link
bucket=bucket
empty=empty
value=value
pending_transitions=pending_transitions
@@ -1885,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>>,
@@ -1917,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
@@ -2177,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>,
@@ -2215,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>
@@ -2248,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>
@@ -2461,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)
}
@@ -2987,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
@@ -779,8 +779,13 @@ pub struct SiteConfig {
/// the way to the browser (see `get_site`), never from content, so
/// a `type: location` field knows whether to offer a map or two
/// number inputs.
#[serde(skip)]
#[serde(default)]
pub maps: bool,
/// Whether this instance has sign-in at all (a provider is
/// configured): set on the way to the browser like `maps`, so the
/// header knows whether to offer it.
#[serde(default)]
pub sign_in: bool,
/// Who this site's mail comes from. Absent means the site sends
/// nothing: a state's `mail:` and the invite mail are logged and
/// skipped, never half-sent (see `mail.rs`).
+3
View File
@@ -22,6 +22,7 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
("en", "if_stuck") => " if you get stuck.",
("en", "send") => "Send",
("en", "sign_in") => "Sign in",
("en", "sign_out") => "Sign out",
("en", "nothing_here_yet") => "Nothing here yet.",
("en", "nothing_here") => "Nothing here",
("en", "back_to_start") => "back to the start",
@@ -51,6 +52,7 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
("no", "if_stuck") => " om du står fast.",
("no", "send") => "Send",
("no", "sign_in") => "Logg inn",
("no", "sign_out") => "Logg ut",
("no", "nothing_here_yet") => "Ingenting her ennå.",
("no", "nothing_here") => "Ingenting her",
("no", "back_to_start") => "tilbake til start",
@@ -98,6 +100,7 @@ mod tests {
"if_stuck",
"send",
"sign_in",
"sign_out",
"nothing_here_yet",
"nothing_here",
"back_to_start",
+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 {
+88 -11
View File
@@ -62,6 +62,12 @@ pub struct Plan {
/// is what the page-load limit asks for.
#[serde(default)]
pub doors: Vec<Door>,
/// Pictures across the front door, under the wordmark and above
/// the question: portal's `slideshow` block, verbatim (`max_height`,
/// `slides` with `image`, `caption`, `width`, `height`). What the
/// business has made, shown before anything is asked.
#[serde(default)]
pub slideshow: Option<Value>,
/// 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
@@ -343,6 +349,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
@@ -432,7 +446,7 @@ impl Field {
/// What a public listing may show: everything but contact details
/// and what the plan marks private.
pub fn is_public(&self) -> bool {
!self.private && self.kind != "email" && self.kind != "tel"
!self.private && self.kind != "email" && self.kind != "tel" && self.kind != "file"
}
}
@@ -474,7 +488,7 @@ impl Plan {
}
}
const FIELD_TYPES: &[&str] = &["text", "email", "tel", "textarea", "date", "select", "location"];
const FIELD_TYPES: &[&str] = &["text", "email", "tel", "textarea", "date", "select", "location", "file"];
fn is_id(s: &str) -> bool {
!s.is_empty() && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
@@ -687,6 +701,14 @@ impl Plan {
bail!(at("a stage that grants a group makes an account for the person, so the form must collect a required `name` (text) and a required `email` (type email)"));
}
}
for group in &need.followed_by {
if !is_id(group) || group == PUBLIC || group == SUBMITTER {
bail!(at(&format!("followed_by must name groups, not {group:?}")));
}
if !need.collects.iter().any(|f| f.name == "email" && f.kind == "email" && !f.optional) {
bail!(at("followed_by shows a person the cases that carry their address, so the form must collect a required `email` of type email"));
}
}
for (stage, e) in &need.escalate {
if e.days == 0 {
bail!(at(&format!("escalate from {stage:?} must wait at least one day")));
@@ -1184,7 +1206,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.")
@@ -1480,7 +1504,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> {
@@ -1493,6 +1526,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()
}
@@ -2040,10 +2074,11 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
plan.front_door_count()
);
}
files.insert(
"questions/index.yaml".into(),
json!({ "name": text(&front["question"]), "description": text(&front["description"]), "responsible": responsible, "alternatives": alternatives }),
);
let mut front_page = json!({ "name": text(&front["question"]), "description": text(&front["description"]), "responsible": responsible, "alternatives": alternatives });
if let Some(slideshow) = &plan.slideshow {
front_page["slideshow"] = slideshow.clone();
}
files.insert("questions/index.yaml".into(), front_page);
// A door's page: a nav page (index.yaml), public, holding the
// forms of the needs behind it.
@@ -2154,6 +2189,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"]),
@@ -2370,6 +2409,8 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
files.into_iter().map(|(path, value)| Ok((path, serde_yaml::to_string(&value)?))).collect()
}
const DESK_PURPOSE: &str = "a desk: the page one role inside the business works from. Under `buckets`, each entry is a list of cases; `moves` are the buttons that move a case from one stage to the next (key `from->to`), so label each with what the person has just done. Under `links` are pages this role can open. Under `invites`, each entry is a form that invites a person into that group - they get an account and a one-time link - and `invited` is the list of invites sent, where `again` is the button that sends an invite whose link ran out once more. `suggestions`, on the desk that has it, is the list of changes the people responsible for a page have suggested: `looking`, `changed` and `declined` are its buttons.";
fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -> String {
let voice = match tone {
Some(tone) => format!("\n\nTone of voice for every word on this page: {tone}. The site is told in more than one tone, each written in a pass of its own, so keep to this one throughout and do not hedge between tones."),
@@ -2385,6 +2426,21 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -
} else {
format!("a public page anyone can open, read-only, listing ONLY the cases that have reached: {shown_only}. Nothing else is shown - do not promise the reader anything waiting or turned down. `question` is what this page asks; `heading` names the list.")
};
// A desk is not always a place of work: a group that only follows
// its own cases reads a list of what it sent in, and the words
// should not hand it a job it does not have.
let desk_group = path.strip_prefix("questions/").and_then(|p| p.split('/').next()).unwrap_or_default();
let own: Vec<&str> = plan.needs.iter().filter(|n| follows_own(n, desk_group)).map(|n| n.lands_in.as_str()).collect();
let mut desk_purpose = DESK_PURPOSE.to_string();
if !own.is_empty() {
desk_purpose.push_str(&format!(" On this page, the list(s) {} show the signed-in person ONLY the cases they sent in themselves: they are following their own case, not working through other people's, so head those lists as theirs ('Your cases') and say nothing of a queue.", own.iter().map(|b| format!("`{b}`")).collect::<Vec<_>>().join(", ")));
}
// A form a role enters from inside is sent by a colleague at work,
// not by a visitor waiting on an answer.
let sent_purpose = match plan.needs.iter().find(|n| n.enters_as.as_deref().is_some_and(|g| path == format!("questions/{g}/{}-sent.yaml", n.id))) {
Some(n) => format!("a page shown right after someone in the group `{}` has sent a form from inside the business. The reader is that colleague, at work, not a visitor: nobody comes back to them. Say what has now happened to what they entered, and who it reaches.", n.enters_as.as_deref().unwrap_or_default()),
None => "a page shown right after someone has sent a form. Say who reads it, when they answer, and what happens next.".to_string(),
};
let purpose = if path == "site.yaml" {
"the invite mail: the first mail a person gets when a member of the business brings them in to help (onto a desk, into a committee), carrying the one-time link that lets them set a password. `subject` and `body` under `invite`. They did not ask for it, so say who brought them in and to what, then the link and when it runs out, then where they sign in afterwards. Plain text, short lines. Keep every `{...}` placeholder exactly as it is: `{name}`, `{username}`, `{group}`, `{link}`, `{expires}`, `{site}`, `{site_name}` are filled in by the site, and `{link}` must stay on a line of its own."
} else if path == "aggregates.yaml" {
@@ -2398,11 +2454,11 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -
} else if plan.doors.iter().any(|d| door_path(d) == path) {
"a page one kind of visitor reaches from the front door. `question` is what this page asks them; under `needs`, each entry is one alternative they can pick, with the form behind it - or, for a need that is members' only, the card that leads to the members' page, where the form is behind sign-in: say so in its description."
} else if path.ends_with("/index.yaml") {
"a desk: the page one role inside the business works from. Under `buckets`, each entry is a list of cases; `moves` are the buttons that move a case from one stage to the next (key `from->to`), so label each with what the person has just done. Under `links` are pages this role can open. Under `invites`, each entry is a form that invites a person into that group - they get an account and a one-time link - and `invited` is the list of invites sent, where `again` is the button that sends an invite whose link ran out once more. `suggestions`, on the desk that has it, is the list of changes the people responsible for a page have suggested: `looking`, `changed` and `declined` are its buttons."
desk_purpose.as_str()
} else if path.ends_with("/[key].yaml") {
"a page one record of a public list opens to. This is NOT where a notice is posted: the visitor has read somebody else's record on the list (a notice, an application) and is answering it. `question` asks the visitor what they have to say to it; under `forms`, each entry is one kind of answer (an objection, an appeal, an offer to lend), with the form behind it. An answer that mails the record's owner goes straight to them; nobody at a desk passes it on, so never say so."
} else if path.contains("/received/") || path.ends_with("-sent.yaml") {
"a page shown right after someone has sent a form. Say who reads it, when they answer, and what happens next."
sent_purpose.as_str()
} else {
"a page only one role inside the business can open, holding one form."
};
@@ -2570,7 +2626,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,
}
@@ -3385,6 +3441,27 @@ 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";
+9
View File
@@ -175,9 +175,18 @@ 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.
- **Show the work.** `slideshow` on the plan is portal's own block,
carried onto the front door as written: a strip of pictures under
the question, each with its caption.
- **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,
+130 -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;
@@ -742,7 +797,7 @@ main.not-found {
.feature p {
color: var(--ink-dim);
font-size: 0.95rem;
font-size: 0.95em;
}
.field {
@@ -753,7 +808,7 @@ main.not-found {
}
.field-label {
font-size: 0.85rem;
font-size: 0.85em;
font-weight: 600;
color: var(--ink-dim);
}
@@ -763,17 +818,74 @@ main.not-found {
margin-left: 0.2em;
}
/* Controls take their size from the text around them (em, not rem):
a site that sets its body larger, or a card that sets itself
smaller, gets fields, pills and buttons to match, and everything
inside a control - the date picker's icon, the file picker's button
- scales with the control. */
input,
textarea {
font: inherit;
color: var(--ink);
background: var(--paper);
border: 0.06rem solid var(--line);
border-radius: 0.6rem;
padding: 0.65rem 0.8rem;
border-radius: 0.6em;
padding: 0.65em 0.8em;
width: 100%;
}
/* The calendar icon at the size of the text it sits beside, and a
target worth aiming at. */
input[type="date"],
input[type="time"],
input[type="datetime-local"] {
min-height: 2.9em;
}
input[type="date"]::-webkit-calendar-picker-indicator,
input[type="time"]::-webkit-calendar-picker-indicator,
input[type="datetime-local"]::-webkit-calendar-picker-indicator {
width: 1.35em;
height: 1.35em;
padding: 0.2em;
margin-left: 0.4em;
cursor: pointer;
opacity: 0.75;
}
input[type="date"]::-webkit-calendar-picker-indicator:hover,
input[type="time"]::-webkit-calendar-picker-indicator:hover,
input[type="datetime-local"]::-webkit-calendar-picker-indicator:hover {
opacity: 1;
}
/* The file picker's own button, in the field's colours rather than
the browser's: it follows whatever ink the field has, so it holds
on a light field and a dark one alike. */
input[type="file"] {
padding: 0.45em;
cursor: pointer;
}
input[type="file"]::file-selector-button {
font: inherit;
font-size: 0.85em;
font-weight: 600;
color: inherit;
background: color-mix(in srgb, currentColor 9%, transparent);
border: 0.06rem solid color-mix(in srgb, currentColor 28%, transparent);
border-radius: 999rem;
padding: 0.4em 1em;
margin-right: 0.8em;
cursor: pointer;
transition: border-color 120ms, background 120ms;
}
input[type="file"]::file-selector-button:hover {
background: var(--accent-soft);
border-color: var(--accent);
}
textarea {
min-height: 5rem;
resize: vertical;
@@ -1034,14 +1146,14 @@ textarea:focus {
}
.alt-submit {
font-family: var(--sans);
font-family: inherit;
font-weight: 600;
font-size: 0.95rem;
font-size: 0.95em;
color: var(--paper);
background: var(--accent);
border: none;
border-radius: 0.7rem;
padding: 0.75rem 1.4rem;
border-radius: 0.7em;
padding: 0.75em 1.4em;
cursor: pointer;
transition: filter 120ms, transform 120ms;
/* A gateway alternative renders this as an <a> (navigation, no POST);
@@ -1157,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;
@@ -1247,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;
}