From 0886869607a673de618a4ded415bdf0fbeabad72 Mon Sep 17 00:00:00 2001
From: Bendik Aagaard Lynghaug
Date: Tue, 29 Sep 2026 15:44:20 +0200
Subject: [PATCH 1/4] Accounts the content mandates, a seed for the first
person, and edits suggested by who is responsible
Every person a page names as responsible gets a Kanidm account at start
if they have none, and a mail saying they are listed as responsible for
asking that question, with the link to sign in; RESPONSIBLE_GROUP puts
them in one group. SEED_ADMIN_EMAIL invites the first person into the
most privileged group - the one whose desk may invite into the most -
once. Signed in, a responsible person may suggest a change to their
own pages; checked on the server, kept, published, and mailed to the
site's inbox.
Fixes an invite of someone new on a Kanidm with only the onboarding
account: the person is created with their address in one call, since
idm_people_on_boarding may create but not modify (it answered 403).
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
---
CHANGELOG.md | 6 +
docs/operations.md | 2 +
src/app.rs | 110 ++++++++++++++++++
src/auth.rs | 4 +
src/i18n.rs | 12 ++
src/lib.rs | 3 +
src/mail.rs | 93 ++++++++++++++++
src/main.rs | 2 +
src/people.rs | 42 ++++---
src/seed.rs | 272 +++++++++++++++++++++++++++++++++++++++++++++
src/server/oidc.rs | 2 +
style/main.css | 28 +++++
12 files changed, 563 insertions(+), 13 deletions(-)
create mode 100644 src/seed.rs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aa00270..53420b9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,12 @@ One line per change, grouped by the release that shipped it. Newest
first. A release is a Cargo.toml bump plus a `v` tag; the
content repos pin the tag they run (see the end of this file).
+## Unreleased
+
+- Accounts the content mandates, made at start (`src/seed.rs`). Every person a page names as `responsible` gets a Kanidm account if they have none, and a mail in the site's language saying they are listed as responsible for asking that question, with the link to sign in; `RESPONSIBLE_GROUP` puts them all in one group (people who already had an account are added without a mail). `SEED_ADMIN_EMAIL` (and `SEED_ADMIN_NAME`, `SEED_GROUP`) invites the first person into the site's most privileged group - the group whose desk may invite into the most groups - so a site with its own Kanidm has someone to invite everyone else. Both happen once per address and group (`portal_seed`), and retry while Kanidm comes up.
+- A page's responsible person, signed in, may suggest a change to it: a card at the foot of their own pages, checked on the server against the page's `responsible.contact`, kept in `portal_edit_suggestions`, published on `portal.edits.suggested`, and mailed to the site's inbox with them as reply-to. The session now carries the signed-in person's email.
+- Fixed: an invite of someone new failed on a Kanidm set up with the onboarding account alone. Portal created the person, then set their address in a second call, and `idm_people_on_boarding` may create but not modify: 403. The address now goes in with the person.
+
## 0.5.3 (2026-09-29)
- A way down the tree from every page: an arrow fixed at the left edge, half-way up, and the same link in words at the foot ("Tilbake til starten", "Back to “What came in?”"). It goes to the nearest page below the current one that the visitor may open, taking the path apart a segment at a time, and to the front door when none is; the front door has none. Content no longer needs a "back" alternative, and a visitor who went up a branch that turned out not to apply is never stranded.
diff --git a/docs/operations.md b/docs/operations.md
index 6c7f849..9b2b497 100644
--- a/docs/operations.md
+++ b/docs/operations.md
@@ -70,6 +70,8 @@ values.
| `CONTENT_REPO`, `CONTENT_BRANCH` | the repo that is the site |
| `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET` | sign-in |
| `KANIDM_API_TOKEN` | invites and `grants:`; unset, neither happens |
+| `SEED_ADMIN_EMAIL`, `SEED_ADMIN_NAME`, `SEED_GROUP` | the first person in, invited once at start into the most privileged group (or `SEED_GROUP`) |
+| `RESPONSIBLE_GROUP` | the group every person a page names as `responsible` joins; they get an account at start either way |
| `PUBLIC_URL`, `SITE_NAME` | what the site calls itself in links and mail |
| `GITEA_API_TOKEN` | authenticated resource pulls |
| `AUTOMATION_READ_TOKEN` | the automation KV read endpoint |
diff --git a/src/app.rs b/src/app.rs
index 91e840f..7c2ff68 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -456,6 +456,13 @@ fn QuestionView(
}
/>
+ // The person the page names as responsible, signed in, may
+ // suggest a change to it; the server checks it is them.
+ {question
+ .responsible
+ .as_ref()
+ .filter(|r| is_responsible(user.as_ref(), &r.contact))
+ .map(|_| view! { })}
{question
.responsible
.clone()
@@ -618,6 +625,47 @@ fn Announcements(current_id: String) -> impl IntoView {
/// bulk scrapers regex HTML for `mailto:` strings, and this keeps one
/// out of what they see. Not a defense against a targeted scrape, just
/// against the generic bot crawl.
+/// Whether the signed-in person is the one a page names as responsible:
+/// their Kanidm address equals the page's `responsible.contact`
+/// (`mailto:` off, case ignored).
+pub fn is_responsible(user: Option<&User>, contact: &str) -> bool {
+ let contact = contact.trim().trim_start_matches("mailto:").trim().to_lowercase();
+ user.and_then(|u| u.email.as_deref()).is_some_and(|e| !contact.is_empty() && e.trim().to_lowercase() == contact)
+}
+
+/// A change suggested by the person responsible for the page: one
+/// field, sent to the site's inbox. Shown only to them.
+#[component]
+fn SuggestEdit(question_id: String) -> impl IntoView {
+ let send = ServerAction::::new();
+ let text = NodeRef::::new();
+ let sent = move || send.value().get().is_some_and(|r| r.is_ok());
+ let on_submit = move |ev: leptos::ev::SubmitEvent| {
+ ev.prevent_default();
+ let value = text.get().map(|t| t.value()).unwrap_or_default();
+ if !value.trim().is_empty() {
+ send.dispatch(SuggestPageEdit { question_id: question_id.clone(), text: value });
+ }
+ };
+ view! {
+
+
{move || t(&lang().get(), "suggest_edit")}
+ {move || t(&lang().get(), "suggest_edit_help")}
+
+ }
+ >
+
{move || t(&lang().get(), "suggest_edit_sent")}
+
+
+ }
+}
+
#[component]
fn ResponsibleNote(responsible: Responsible) -> impl IntoView {
let (user, domain) = responsible
@@ -2433,6 +2481,49 @@ pub struct SubmitResult {
pub chain_hash: String,
}
+/// A change to a page, suggested by the person it names as responsible.
+/// Checked here, not trusted from the page: the session's address must
+/// be the page's responsible contact. Kept in `portal_edit_suggestions`,
+/// announced on `portal.edits.suggested`, and mailed to the site's inbox.
+#[server(endpoint = "suggest_page_edit")]
+pub async fn suggest_page_edit(question_id: String, text: String) -> Result<(), ServerFnError> {
+ use crate::auth::{User, SESSION_USER_KEY};
+ use crate::server::AppState;
+
+ let state = expect_context::();
+ let session: tower_sessions::Session = leptos_axum::extract().await?;
+ let user = session.get::(SESSION_USER_KEY).await.map_err(|e| ServerFnError::new(e.to_string()))?;
+ let text = text.trim().to_string();
+ if text.is_empty() || text.len() > 8000 {
+ return Err(ServerFnError::new("a suggestion is between one and 8000 characters"));
+ }
+ let question = state.questions.load().get(&question_id).cloned().ok_or_else(|| ServerFnError::new("no such page"))?;
+ let responsible = question.responsible.clone().ok_or_else(|| ServerFnError::new("this page names no one responsible"))?;
+ if !is_responsible(user.as_ref(), &responsible.contact) {
+ return Err(ServerFnError::new("only the person responsible for this page may suggest changes to it here"));
+ }
+ let user = user.expect("is_responsible needs a user");
+ let email = user.email.clone().unwrap_or_default();
+ let now_ms = chrono::Utc::now().timestamp_millis();
+ let record = serde_json::json!({
+ "question_id": question_id, "question": question.name,
+ "by": user.username, "email": email, "text": text, "submitted_ms": now_ms,
+ });
+ let store = match state.jetstream.get_key_value("portal_edit_suggestions").await {
+ Ok(store) => store,
+ Err(_) => state
+ .jetstream
+ .create_key_value(async_nats::jetstream::kv::Config { bucket: "portal_edit_suggestions".to_string(), ..Default::default() })
+ .await
+ .map_err(|e| ServerFnError::new(e.to_string()))?,
+ };
+ let bytes = serde_json::to_vec(&record).map_err(|e| ServerFnError::new(e.to_string()))?;
+ store.put(format!("{now_ms}-{}", user.username.replace(|c: char| !c.is_ascii_alphanumeric(), "_")), bytes.clone().into()).await.map_err(|e| ServerFnError::new(e.to_string()))?;
+ let _ = state.nats.publish("portal.edits.suggested", bytes.into()).await;
+ crate::mail::on_edit_suggestion(&state, &question_id, &question.name, &user.display_name, &email, &text).await;
+ Ok(())
+}
+
#[server(endpoint = "submit_answer")]
pub async fn submit_answer(
question_id: String,
@@ -2681,3 +2772,22 @@ mod down_the_tree_tests {
assert_eq!(down_the_tree("/melde-fra", &pages(&["/melde-fra"])).unwrap().0, "/");
}
}
+
+#[cfg(test)]
+mod responsible_tests {
+ use super::is_responsible;
+ use crate::auth::User;
+
+ fn user(email: Option<&str>) -> User {
+ User { sub: "s".into(), username: "kari".into(), display_name: "Kari".into(), groups: vec![], email: email.map(String::from) }
+ }
+
+ #[test]
+ fn only_the_person_a_page_names_may_suggest_a_change_to_it() {
+ assert!(is_responsible(Some(&user(Some("kari@vel.no"))), "mailto:Kari@Vel.no"));
+ assert!(!is_responsible(Some(&user(Some("ola@vel.no"))), "kari@vel.no"));
+ assert!(!is_responsible(Some(&user(None)), "kari@vel.no"));
+ assert!(!is_responsible(None, "kari@vel.no"));
+ assert!(!is_responsible(Some(&user(Some(""))), ""));
+ }
+}
diff --git a/src/auth.rs b/src/auth.rs
index b6b722e..aabbbc0 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -14,6 +14,10 @@ pub struct User {
/// live, so a group change only takes effect on the next login.
#[serde(default)]
pub groups: Vec,
+ /// The address Kanidm holds for them (the `email` claim), so a page
+ /// can know it is talking to the person it names as responsible.
+ #[serde(default)]
+ pub email: Option,
}
pub const SESSION_USER_KEY: &str = "user";
diff --git a/src/i18n.rs b/src/i18n.rs
index 8875823..84718f4 100644
--- a/src/i18n.rs
+++ b/src/i18n.rs
@@ -34,6 +34,10 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
}
("en", "announcements") => "Announcements",
("en", "down_to") => "Back to ",
+ ("en", "suggest_edit") => "Suggest a change to this page",
+ ("en", "suggest_edit_help") => "You are listed as responsible for this question. Say what should change, and it goes to the people who keep the site.",
+ ("en", "suggest_edit_send") => "Send the suggestion",
+ ("en", "suggest_edit_sent") => "Thank you - your suggestion is sent.",
("en", "the_start") => "the start",
("no", "also_worth_asking") => "Også verdt å spørre",
@@ -54,6 +58,10 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
}
("no", "announcements") => "Kunngjøringer",
("no", "down_to") => "Tilbake til ",
+ ("no", "suggest_edit") => "Foreslå en endring på denne siden",
+ ("no", "suggest_edit_help") => "Du står oppført som ansvarlig for dette spørsmålet. Skriv hva som bør endres, så går det til dem som har siden.",
+ ("no", "suggest_edit_send") => "Send forslaget",
+ ("no", "suggest_edit_sent") => "Takk - forslaget er sendt.",
("no", "the_start") => "starten",
_ => return None,
@@ -89,6 +97,10 @@ mod tests {
"announcements",
"down_to",
"the_start",
+ "suggest_edit",
+ "suggest_edit_help",
+ "suggest_edit_send",
+ "suggest_edit_sent",
] {
assert!(lookup("en", key).is_some(), "en missing {key}");
assert!(lookup("no", key).is_some(), "no missing {key}");
diff --git a/src/lib.rs b/src/lib.rs
index aef99d5..90aedbc 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -31,6 +31,9 @@ pub mod people;
#[cfg(feature = "ssr")]
pub mod mail;
+#[cfg(feature = "ssr")]
+pub mod seed;
+
pub mod maps;
#[cfg(feature = "ssr")]
diff --git a/src/mail.rs b/src/mail.rs
index a16cef1..2fa11ac 100644
--- a/src/mail.rs
+++ b/src/mail.rs
@@ -301,6 +301,99 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
}
}
+/// The mail a person gets when the content names them responsible for
+/// a question and they had no account: who they are to the site (the
+/// person listed as asking that question), and the link that lets them
+/// sign in and suggest changes to it. Built in, in the site's language.
+pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::people::Invited, questions: &[String]) {
+ let site = state.site.load();
+ let Some(cfg) = &site.mail else {
+ tracing::warn!(user = %invited.username, "an account was made for a responsible person, but site.yaml has no `mail.from` - they were not told");
+ return;
+ };
+ let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
+ let quoted: Vec = questions.iter().map(|q| if norwegian { format!("«{q}»") } else { format!("\u{201c}{q}\u{201d}") }).collect();
+ let list = match quoted.len() {
+ 0 => String::new(),
+ 1 => quoted[0].clone(),
+ n => format!("{} {} {}", quoted[..n - 1].join(", "), if norwegian { "og" } else { "and" }, quoted[n - 1]),
+ };
+ let (subject, body) = if norwegian {
+ (
+ "Du står som ansvarlig for et spørsmål hos {site_name}".to_string(),
+ "Hei {name},\n\nDu står oppført som ansvarlig for å stille {questions} hos {site_name}. Derfor har du fått en konto der, så du kan logge inn og foreslå endringer i det du står bak.\n\nVelg passord eller passnøkkel her, lenken virker fram til {expires}:\n\n{link}\n\nBrukernavnet ditt er {username}. Etterpå logger du inn på {site}.".to_string(),
+ )
+ } else {
+ (
+ "You are listed as responsible for a question at {site_name}".to_string(),
+ "Hello {name},\n\nYou are listed as responsible for asking {questions} at {site_name}. So you now have an account there, to sign in and suggest changes to what you stand behind.\n\nSet your password or passkey here; the link works until {expires}:\n\n{link}\n\nYour username is {username}. Afterwards you sign in at {site}.".to_string(),
+ )
+ };
+ let now_ms = chrono::Utc::now().timestamp_millis();
+ let expires = invited
+ .link_expires_ms
+ .and_then(chrono::DateTime::::from_timestamp_millis)
+ .map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string())
+ .unwrap_or_default();
+ let mut vars = serde_json::Map::new();
+ vars.insert("name".into(), invited.display_name.clone().into());
+ vars.insert("username".into(), invited.username.clone().into());
+ vars.insert("questions".into(), list.into());
+ vars.insert("link".into(), invited.reset_link.clone().unwrap_or_default().into());
+ vars.insert("site".into(), public_url().into());
+ vars.insert("site_name".into(), site_name(&site).into());
+ vars.insert("expires".into(), expires.into());
+ let text = render(&body, &vars);
+ let mail = Outgoing {
+ v: CONTRACT,
+ id: format!("responsible:{}:{now_ms}", invited.username),
+ site: site_name(&site),
+ from: cfg.from.clone(),
+ reply_to: cfg.reply_to.clone(),
+ to: invited.email.clone(),
+ subject: render(&subject, &vars),
+ html: to_html(&text),
+ text,
+ };
+ if let Err(e) = publish(&state.jetstream, &mail).await {
+ tracing::error!(user = %invited.username, "publishing the responsible mail failed: {e}");
+ }
+}
+
+/// A change suggested by the person responsible for a page, to the
+/// site's own inbox (`mail.reply_to`, else `mail.from`), with the
+/// suggester as reply-to so answering the mail answers them.
+pub async fn on_edit_suggestion(state: &crate::server::AppState, question_id: &str, question_name: &str, by_name: &str, by_email: &str, text: &str) {
+ let site = state.site.load();
+ let Some(cfg) = &site.mail else {
+ tracing::warn!(question_id, "an edit was suggested, but site.yaml has no `mail.from` - it is in portal_edit_suggestions only");
+ return;
+ };
+ let to = cfg.reply_to.clone().unwrap_or_else(|| cfg.from.clone());
+ let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
+ let (subject, lead) = if norwegian {
+ (format!("Forslag til endring: «{question_name}»"), format!("{by_name} ({by_email}), som står som ansvarlig for spørsmålet, foreslår en endring på {}{question_id}:", public_url()))
+ } else {
+ (format!("Suggested change: \u{201c}{question_name}\u{201d}"), format!("{by_name} ({by_email}), listed as responsible for the question, suggests a change to {}{question_id}:", public_url()))
+ };
+ let body = format!("{lead}\n\n{text}");
+ let now_ms = chrono::Utc::now().timestamp_millis();
+ let mail = Outgoing {
+ v: CONTRACT,
+ id: format!("suggestion:{question_id}:{now_ms}"),
+ site: site_name(&site),
+ from: cfg.from.clone(),
+ reply_to: Some(by_email.to_string()),
+ to,
+ subject,
+ html: to_html(&body),
+ text: body,
+ };
+ if let Err(e) = publish(&state.jetstream, &mail).await {
+ tracing::error!(question_id, "publishing the suggestion mail failed: {e}");
+ }
+}
+
/// The invite mail: the one-time link to the person just onboarded.
/// Built in, since portal knows everything about it; `mail.invite` in
/// site.yaml rewords it. `{name}`, `{email}`, `{group}`, `{link}`,
diff --git a/src/main.rs b/src/main.rs
index 4f4e772..80c328d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -108,6 +108,8 @@ async fn main() -> anyhow::Result<()> {
// windows (see announce.rs).
tokio::spawn(portal::announce::run(state.clone()));
tokio::spawn(portal::deadlines::run(state.clone()));
+ // The first person in, when SEED_ADMIN_EMAIL names one (seed.rs).
+ tokio::spawn(portal::seed::run(state.clone()));
// Replies to case mail, matched to their record by whatever reads
// the mailbox (uhhm/gdo over JMAP) and turned into notes here. A
diff --git a/src/people.rs b/src/people.rs
index e8d6af5..977ae47 100644
--- a/src/people.rs
+++ b/src/people.rs
@@ -109,34 +109,50 @@ impl Kanidm {
/// username, is only added to the group; their credentials are
/// left alone and no link is made.
pub async fn invite(&self, name: &str, email: &str, group: &str) -> anyhow::Result {
+ self.onboard(name, email, Some(group)).await
+ }
+
+ /// An account and nothing more: for a person the content names as
+ /// responsible for a page, who needs to be able to sign in whether
+ /// or not they sit at any desk. `group` is empty in what comes back.
+ pub async fn account(&self, name: &str, email: &str) -> anyhow::Result {
+ self.onboard(name, email, None).await
+ }
+
+ /// Whether Kanidm already holds a person with this address.
+ pub async fn has_person_with_mail(&self, email: &str) -> anyhow::Result {
+ Ok(self.find_by_mail(email).await?.is_some())
+ }
+
+ async fn onboard(&self, name: &str, email: &str, group: Option<&str>) -> anyhow::Result {
let existing = self.find_by_mail(email).await?;
let username = existing.clone().unwrap_or_else(|| username_for(email));
let created = if existing.is_some() || self.person_exists(&username).await? {
false
} else {
+ // The address goes in with the person, in one call: the
+ // onboarding account may create a person but not modify one
+ // (idm_people_on_boarding), so a separate PUT of `mail`
+ // afterwards is refused with 403 and the invite fails.
let (status, text) = self
.call(
openidconnect::reqwest::Method::POST,
"/v1/person",
- Some(serde_json::json!({ "attrs": { "name": [username], "displayname": [name] } })),
+ Some(serde_json::json!({ "attrs": { "name": [username], "displayname": [name], "mail": [email] } })),
)
.await?;
if !(200..300).contains(&status) {
anyhow::bail!("kanidm: creating {username}: http {status}: {text}");
}
- let (status, text) = self
- .call(openidconnect::reqwest::Method::PUT, &format!("/v1/person/{username}/_attr/mail"), Some(serde_json::json!([email])))
- .await?;
- if !(200..300).contains(&status) {
- anyhow::bail!("kanidm: setting mail on {username}: http {status}: {text}");
- }
true
};
- let (status, text) = self
- .call(openidconnect::reqwest::Method::POST, &format!("/v1/group/{group}/_attr/member"), Some(serde_json::json!([username])))
- .await?;
- if !(200..300).contains(&status) {
- anyhow::bail!("kanidm: adding {username} to {group}: http {status}: {text}");
+ if let Some(group) = group {
+ let (status, text) = self
+ .call(openidconnect::reqwest::Method::POST, &format!("/v1/group/{group}/_attr/member"), Some(serde_json::json!([username])))
+ .await?;
+ if !(200..300).contains(&status) {
+ anyhow::bail!("kanidm: adding {username} to {group}: http {status}: {text}");
+ }
}
let (reset_link, link_expires_ms) = if created {
// 24 hours: the longest Kanidm allows, since the link travels
@@ -163,7 +179,7 @@ impl Kanidm {
username,
display_name: name.to_string(),
email: email.to_string(),
- group: group.to_string(),
+ group: group.unwrap_or_default().to_string(),
reset_link,
link_expires_ms,
created,
diff --git a/src/seed.rs b/src/seed.rs
new file mode 100644
index 0000000..d76dc8e
--- /dev/null
+++ b/src/seed.rs
@@ -0,0 +1,272 @@
+//! Accounts the content mandates, made at start.
+//!
+//! **The first person in.** A site with its own Kanidm starts with nobody
+//! on any desk, and every later person is invited from a desk - so the
+//! first one has to come from somewhere else. `SEED_ADMIN_EMAIL` names
+//! them: at start, portal invites that address into the site's most
+//! privileged group and mails them the one-time link, exactly as a desk
+//! invite would. From there they invite everyone else themselves.
+//!
+//! - `SEED_ADMIN_EMAIL` - who; unset, nothing happens.
+//! - `SEED_ADMIN_NAME` - their display name; defaults to the address's
+//! local part.
+//! - `SEED_GROUP` - the group; defaults to the one whose desk may invite
+//! into the most groups (the board, on a vel), read from the content.
+//!
+//! Once, not on every start: a done seed is remembered in the
+//! `portal_seed` bucket, keyed by address and group, so a restart or an
+//! upgrade never mails a second link. Changing `SEED_ADMIN_EMAIL` or
+//! `SEED_GROUP` seeds the new pair. Kanidm and gdo may still be coming
+//! up alongside portal, so a failed attempt is retried for a while.
+//!
+//! **Everyone the content names as responsible.** A page's
+//! `responsible: { name, contact }` is a person answering for that
+//! question, so they get an account: at every start, each responsible
+//! address Kanidm does not already hold is given one, and mailed that
+//! they are listed as responsible for asking those questions, with the
+//! link to sign in. Signed in, their own pages offer them a way to
+//! suggest changes. `RESPONSIBLE_GROUP` names a group they all join -
+//! on the vel, `redaktor`, which is also who may sign in to the project
+//! Gitea - and people who already had an account are added to it
+//! without a mail. A desk is still a desk invite. Each address is
+//! onboarded once per group (`portal_seed`).
+#![cfg(feature = "ssr")]
+
+use std::collections::{BTreeMap, BTreeSet, HashMap};
+
+use crate::content::Question;
+
+pub const SEED_BUCKET: &str = "portal_seed";
+
+/// The group whose members may invite into the most groups: a page
+/// gated to group G carrying an `invite: { group: X }` alternative
+/// means G may bring people into X. Ties go to the name that sorts
+/// first, so the choice is stable across starts. `None` when no page
+/// invites anyone.
+pub fn most_privileged_group(questions: &HashMap) -> Option {
+ let mut reach: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
+ for q in questions.values() {
+ let Some(group) = q.qualifies.as_deref() else { continue };
+ for alt in &q.alternatives {
+ if let Some(invite) = &alt.invite {
+ reach.entry(group).or_default().insert(invite.group.as_str());
+ }
+ }
+ }
+ reach
+ .iter()
+ .max_by(|(a, x), (b, y)| x.len().cmp(&y.len()).then_with(|| b.cmp(a)))
+ .map(|(group, _)| group.to_string())
+}
+
+/// The bucket key for one seed: stable, and within NATS key rules.
+pub fn seed_key(email: &str, group: &str) -> String {
+ let clean = |s: &str| s.trim().to_lowercase().chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect::();
+ format!("{}.{}", clean(group), clean(email))
+}
+
+/// Everyone the content names as responsible, by address (lowercased,
+/// `mailto:` taken off): their name as the first page gives it, and the
+/// questions they answer for, in page order.
+pub fn responsible_people(questions: &HashMap) -> BTreeMap)> {
+ let mut ids: Vec<&String> = questions.keys().collect();
+ ids.sort();
+ let mut out: BTreeMap)> = BTreeMap::new();
+ for id in ids {
+ let q = &questions[id];
+ let Some(r) = &q.responsible else { continue };
+ let email = contact_email(&r.contact);
+ let Some(email) = email else { continue };
+ let entry = out.entry(email).or_insert_with(|| (r.name.trim().to_string(), Vec::new()));
+ if !entry.1.contains(&q.name) {
+ entry.1.push(q.name.clone());
+ }
+ }
+ out
+}
+
+/// A responsible `contact` as an address: `mailto:` off, lowercased;
+/// `None` for a phone number or anything else that is not one.
+pub fn contact_email(contact: &str) -> Option {
+ let c = contact.trim().trim_start_matches("mailto:").trim().to_lowercase();
+ (c.contains('@') && !c.contains(char::is_whitespace)).then_some(c)
+}
+
+/// The first person in, then everyone the content names as responsible.
+pub async fn run(state: crate::server::AppState) {
+ seed_admin(&state).await;
+ responsible(&state).await;
+}
+
+async fn seed_bucket(state: &crate::server::AppState) -> Option {
+ match state.jetstream.get_key_value(SEED_BUCKET).await {
+ Ok(store) => Some(store),
+ Err(_) => match state.jetstream.create_key_value(async_nats::jetstream::kv::Config { bucket: SEED_BUCKET.to_string(), ..Default::default() }).await {
+ Ok(store) => Some(store),
+ Err(e) => {
+ tracing::error!("the seed bucket could not be made: {e} - making no accounts, so as not to make them twice");
+ None
+ }
+ },
+ }
+}
+
+async fn responsible(state: &crate::server::AppState) {
+ let people = responsible_people(&state.questions.load());
+ if people.is_empty() {
+ return;
+ }
+ let Some(kanidm) = crate::people::Kanidm::from_env() else {
+ tracing::info!(people = people.len(), "the content names responsible people, but invites are not configured (KANIDM_API_TOKEN) - no accounts made");
+ return;
+ };
+ let Some(store) = seed_bucket(state).await else { return };
+ let group = std::env::var("RESPONSIBLE_GROUP").ok().map(|g| g.trim().to_string()).filter(|g| !g.is_empty());
+ for (email, (name, questions)) in people {
+ let key = seed_key(&email, group.as_deref().unwrap_or("responsible"));
+ if let Ok(Some(_)) = store.get(&key).await {
+ continue;
+ }
+ let name = if name.is_empty() { email.split('@').next().unwrap_or("").to_string() } else { name };
+ // With a group, one call makes the account if there is none and
+ // adds them either way; without one, only a missing account is
+ // made. Either way a person is mailed only when an account was
+ // made for them: someone who had one already has their way in.
+ let made = match &group {
+ Some(group) => kanidm.invite(&name, &email, group).await,
+ None => match kanidm.has_person_with_mail(&email).await {
+ Ok(true) => {
+ let _ = store.put(&key, "existing".into()).await;
+ continue;
+ }
+ Ok(false) => kanidm.account(&name, &email).await,
+ Err(e) => Err(e),
+ },
+ };
+ match made {
+ Ok(invited) => {
+ if let Ok(bytes) = serde_json::to_vec(&invited) {
+ let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.clone().into()).await;
+ let _ = store.put(&key, bytes.into()).await;
+ }
+ if invited.created {
+ crate::mail::on_responsible(state, &invited, &questions).await;
+ }
+ tracing::info!(user = %invited.username, created = invited.created, group = group.as_deref().unwrap_or(""), "a person the content names as responsible has their account");
+ }
+ Err(e) => tracing::warn!(%email, "could not set up a responsible person, leaving them for the next start: {e}"),
+ }
+ }
+}
+
+/// The first person in, once, if `SEED_ADMIN_EMAIL` asks for one.
+async fn seed_admin(state: &crate::server::AppState) {
+ let Some(email) = std::env::var("SEED_ADMIN_EMAIL").ok().map(|e| e.trim().to_string()).filter(|e| e.contains('@')) else {
+ return;
+ };
+ let name = std::env::var("SEED_ADMIN_NAME")
+ .ok()
+ .map(|n| n.trim().to_string())
+ .filter(|n| !n.is_empty())
+ .unwrap_or_else(|| email.split('@').next().unwrap_or("admin").to_string());
+ let group = match std::env::var("SEED_GROUP").ok().map(|g| g.trim().to_string()).filter(|g| !g.is_empty()) {
+ Some(g) => g,
+ None => match most_privileged_group(&state.questions.load()) {
+ Some(g) => g,
+ None => {
+ tracing::warn!("SEED_ADMIN_EMAIL is set, but no page invites anyone and SEED_GROUP is unset - nobody to seed into");
+ return;
+ }
+ },
+ };
+ let key = seed_key(&email, &group);
+ let Some(store) = seed_bucket(state).await else { return };
+ if let Ok(Some(_)) = store.get(&key).await {
+ tracing::info!(group, "the first person was already invited into this group; not again");
+ return;
+ }
+ let Some(kanidm) = crate::people::Kanidm::from_env() else {
+ tracing::warn!(group, "SEED_ADMIN_EMAIL is set, but invites are not configured on this instance (KANIDM_URL, KANIDM_API_TOKEN) - nobody was seeded");
+ return;
+ };
+
+ // Kanidm may be starting beside us: a minute apart, for twenty.
+ let mut attempt = 0;
+ let invited = loop {
+ attempt += 1;
+ match kanidm.invite(&name, &email, &group).await {
+ Ok(invited) => break invited,
+ Err(e) if attempt < 20 => {
+ tracing::warn!(group, attempt, "seeding the first person failed, trying again in a minute: {e}");
+ tokio::time::sleep(std::time::Duration::from_secs(60)).await;
+ }
+ Err(e) => {
+ tracing::error!(group, "seeding the first person failed for good: {e}");
+ return;
+ }
+ }
+ };
+ if let Ok(bytes) = serde_json::to_vec(&invited) {
+ let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.clone().into()).await;
+ if let Err(e) = store.put(&key, bytes.into()).await {
+ tracing::error!(group, "the first person is invited, but it could not be remembered: {e} - a restart would invite them again");
+ }
+ }
+ crate::mail::on_invite(state, &invited).await;
+ tracing::info!(group, user = %invited.username, created = invited.created, "the first person is invited");
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn page(id: &str, qualifies: Option<&str>, invites: &[&str]) -> (String, Question) {
+ let alternatives: String = invites
+ .iter()
+ .map(|g| format!(" - name: Invite into {g}\n invite: {{ group: {g} }}\n"))
+ .collect();
+ let yaml = format!("name: Q?\n{}alternatives:\n{}", qualifies.map(|q| format!("qualifies: {q}\n")).unwrap_or_default(), if alternatives.is_empty() { " - name: A\n".to_string() } else { alternatives });
+ let mut q: Question = serde_yaml::from_str(&yaml).unwrap();
+ q.id = id.to_string();
+ (id.to_string(), q)
+ }
+
+ #[test]
+ fn the_first_person_goes_where_the_most_invitations_can_be_made() {
+ let qs: HashMap = [
+ page("/", None, &[]),
+ page("/styret", Some("styret"), &["styret", "kasserer", "trafikk", "komiteer"]),
+ page("/kasserer", Some("kasserer"), &["kasserer"]),
+ page("/trafikk", Some("trafikk"), &["trafikk"]),
+ ]
+ .into_iter()
+ .collect();
+ assert_eq!(most_privileged_group(&qs).as_deref(), Some("styret"));
+ // A tie goes to the name that sorts first, every time.
+ let tie: HashMap = [page("/b", Some("b"), &["x"]), page("/a", Some("a"), &["y"])].into_iter().collect();
+ assert_eq!(most_privileged_group(&tie).as_deref(), Some("a"));
+ // No invitations anywhere: nobody to seed into without SEED_GROUP.
+ let none: HashMap = [page("/", None, &[])].into_iter().collect();
+ assert!(most_privileged_group(&none).is_none());
+ }
+
+ #[test]
+ fn everyone_named_responsible_is_found_once_by_their_address() {
+ let mut qs: HashMap = HashMap::new();
+ for (id, name, contact) in [("/", "Hva gjør vi?", "mailto:Styret@Vel.no"), ("/melde-fra", "Hva vil du melde?", "styret@vel.no"), ("/b", "Ringe?", "+47 900 00 000"), ("/c", "Trafikk?", "trafikk@vel.no")] {
+ let mut q: Question = serde_yaml::from_str(&format!("name: \"{name}\"\nresponsible: {{ name: Styret, contact: \"{contact}\" }}\nalternatives:\n - name: A\n")).unwrap();
+ q.id = id.to_string();
+ qs.insert(id.to_string(), q);
+ }
+ let people = responsible_people(&qs);
+ assert_eq!(people.keys().collect::>(), ["styret@vel.no", "trafikk@vel.no"]);
+ assert_eq!(people["styret@vel.no"].1, ["Hva gjør vi?", "Hva vil du melde?"]);
+ assert_eq!(contact_email("+47 900 00 000"), None);
+ }
+
+ #[test]
+ fn a_seed_is_remembered_by_address_and_group() {
+ assert_eq!(seed_key(" Kari.Lien@Tomtervel.no ", "styret"), "styret.kari_lien_tomtervel_no");
+ assert_ne!(seed_key("kari@x.no", "styret"), seed_key("kari@x.no", "kasserer"));
+ }
+}
diff --git a/src/server/oidc.rs b/src/server/oidc.rs
index 8dcc032..0bf8930 100644
--- a/src/server/oidc.rs
+++ b/src/server/oidc.rs
@@ -241,11 +241,13 @@ pub async fn callback(
// raw compact JWT string "header.payload.signature".
let groups = extract_groups_claim(&id_token);
+ let email = claims.email().map(|e| e.as_str().trim().to_lowercase());
let user = User {
sub: claims.subject().as_str().to_string(),
username,
display_name,
groups,
+ email,
};
// Rotate the session id on privilege change, then store the user.
diff --git a/style/main.css b/style/main.css
index 5f8e43d..8ac8251 100644
--- a/style/main.css
+++ b/style/main.css
@@ -358,6 +358,34 @@ main.not-found {
border-bottom-color: var(--accent);
}
+/* Only for the person a page names as responsible: a quiet card at
+ the foot of their own page for suggesting a change to it. */
+.suggest-edit {
+ position: relative;
+ z-index: 1;
+ max-width: 44rem;
+ margin: 2.4rem auto 0;
+ padding: 1.2rem 1.4rem;
+ border: 0.06rem dashed var(--line);
+ border-radius: var(--radius);
+ background: var(--card-bg);
+}
+
+.suggest-edit h2 {
+ font-size: 1.05rem;
+ margin: 0 0 0.4rem;
+}
+
+.suggest-edit p {
+ color: var(--ink-dim);
+ margin: 0 0 0.8rem;
+}
+
+.suggest-edit textarea {
+ width: 100%;
+ margin-bottom: 0.7rem;
+}
+
.question-nav {
display: flex;
flex-wrap: wrap;
--
2.54.0
From 641c5f1bd319e4051dbef79e77c84dabdccfa4f6 Mon Sep 17 00:00:00 2001
From: Bendik Aagaard Lynghaug
Date: Tue, 29 Sep 2026 18:02:02 +0200
Subject: [PATCH 2/4] Docs: getting started, bringing your people in
From an empty Kanidm to everyone at their own desk: what in the content
decides who needs an account, the four things Kanidm needs for portal,
the first person, what happens at start, how everyone after comes in
from the desks, what the people who ask the questions can do, and what
each failure looks like.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
---
docs/README.md | 1 +
docs/getting-started.md | 145 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 146 insertions(+)
create mode 100644 docs/getting-started.md
diff --git a/docs/README.md b/docs/README.md
index 1715c4b..e88965d 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -6,6 +6,7 @@ engine does with it.
| | |
|---|---|
+| [Getting started](getting-started.md) | Bringing your people in: from an empty Kanidm to everyone at their own desk, from what the content says. |
| [Architecture](architecture.md) | What the engine is made of and where each piece lives in the source. |
| [Content reference](content-reference.md) | Every key a content repo may write, and what it does. |
| [State machines](state-machines.md) | `aggregates.yaml`: stages, who moves a case, mail, deadlines, memberships. |
diff --git a/docs/getting-started.md b/docs/getting-started.md
new file mode 100644
index 0000000..08de04e
--- /dev/null
+++ b/docs/getting-started.md
@@ -0,0 +1,145 @@
+# Getting started: bringing your people in
+
+A site is run by a handful of people: whoever answers for each question
+it asks, and whoever works each desk. This is how they get in - from
+an empty Kanidm to everyone signed in at their own desk - without
+anyone handing out passwords or running an admin tool per person.
+
+The idea in one line: **the content says who is involved, and portal
+makes their accounts.** A page names who is responsible for it; a
+directory is gated on a group; a desk may invite into groups. Portal
+reads that and does the rest at start.
+
+## What you need
+
+- A content repo, and a portal instance running it
+ ([Operations](operations.md) has adding a site).
+- A Kanidm of the site's own. Group names below are exactly the ones
+ the content uses, with no prefix; that is only safe when the Kanidm
+ serves this site alone.
+- Mail: `mail: { from }` in `site.yaml`, and gdo on the host. Without
+ it accounts are still made, but nobody hears about them.
+
+## 1. Say who is involved, in the content
+
+Three things in the content decide who needs an account:
+
+| content | who | what they get |
+|---|---|---|
+| `responsible: { name, contact }` on a page | the person answering for that question | an account, and a mail saying they are listed as responsible for asking it |
+| `qualifies: styret` on a page or `_section.yaml` | members of that group | the pages and desks under it |
+| `invite: { group: kasserer }` on a desk page | the desk's own group | the right to bring people into `kasserer` |
+
+The desk whose group may invite into the most groups is the site's most
+privileged one; on a vel that is the board. Portal works that out from
+the content (see step 4).
+
+## 2. Set up Kanidm for portal
+
+Portal needs four things from Kanidm. The
+[tomtervel/infrastructure](https://prosjekt.klingenbergbygg.no/tomtervel/infrastructure)
+repo's `kanidm-setup.sh` does all of them and is a good starting point;
+by hand, as `idm_admin`:
+
+```sh
+K="kanidm -D idm_admin -H https://id.example.no"
+
+# An onboarding account: may create people and issue a first password
+# link, nothing more. pii_read lets an invite find someone who already
+# has an account by their email instead of making a second one.
+$K service-account create portal-onboarding "Portal onboarding" idm_admin
+$K group add-members idm_people_on_boarding portal-onboarding
+$K group add-members idm_people_pii_read portal-onboarding
+
+# One group per `qualifies:` in the content, managed by that account:
+# adding a member to a group takes the right to manage it.
+for g in styret kasserer trafikk; do
+ $K group create "$g" portal-onboarding
+done
+
+# The sign-in client. Everyone in this Kanidm may sign in; what they
+# see comes from their groups, in the `groups` claim.
+$K system oauth2 create portal "My site" https://site.example.no
+$K system oauth2 add-redirect-url portal https://site.example.no/auth/callback
+$K system oauth2 update-scope-map portal idm_all_persons openid profile email
+for g in styret kasserer trafikk; do
+ $K system oauth2 update-claim-map portal groups "$g" "$g"
+done
+$K system oauth2 update-claim-map-join portal groups array
+
+# The onboarding token, read-write, shown once: into portal's env.
+$K service-account api-token generate portal-onboarding portal --readwrite
+```
+
+Then portal's environment:
+
+| variable | value |
+|---|---|
+| `KANIDM_URL` | `https://id.example.no` |
+| `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET` | the client above (`show-basic-secret`) |
+| `KANIDM_API_TOKEN` | the onboarding token |
+
+## 3. Name the first person
+
+Someone has to be first: every later person is invited from a desk,
+and on an empty Kanidm there is no desk anyone can open yet.
+
+| variable | value |
+|---|---|
+| `SEED_ADMIN_EMAIL` | the first person's address |
+| `SEED_ADMIN_NAME` | their name, as it should appear (optional) |
+| `SEED_GROUP` | a group to put them in, if not the most privileged one (optional) |
+| `RESPONSIBLE_GROUP` | a group every responsible person joins, e.g. `redaktor` (optional) |
+
+## 4. Start portal
+
+At start, portal:
+
+1. **Invites the first person** into `SEED_GROUP`, or the most
+ privileged group, and mails them the one-time link. Once: it is
+ remembered in the `portal_seed` bucket, so restarts and upgrades
+ never send a second link.
+2. **Makes an account for everyone named responsible** who has none,
+ and mails them that they are listed as responsible for asking those
+ questions, with their link. With `RESPONSIBLE_GROUP` they all join
+ it; someone who already had an account is added without a mail.
+
+Kanidm may still be starting beside portal, so a failed attempt is
+tried again, a minute apart. The log says what happened, per person.
+
+## 5. The rest come in from the desks
+
+From here nobody touches Kanidm:
+
+- **At a desk**, whoever may invite fills in a name and an address.
+ Portal makes the account, adds them to the group, and mails the link.
+ The invites sent are listed on that desk.
+- **A decision can be the invitation.** A state with `grants: group`
+ in `aggregates.yaml` puts the person a case is about into that group
+ when the case arrives there - an approved committee application that
+ *is* the membership.
+- **A new page with a new responsible person** gets them an account at
+ the next start.
+
+## 6. What the people who ask the questions can do
+
+Signed in, a person a page names as responsible sees a card at the
+foot of their own pages to suggest a change. The server checks it is
+really them; the suggestion is kept (`portal_edit_suggestions`),
+announced (`portal.edits.suggested`), and mailed to the site's inbox
+with them as reply-to, so answering it answers them.
+
+Their group can also be the way into other tools. The vel's
+`redaktor` group signs in to the project Gitea with the same account:
+a second OAuth2 client scoped to that group alone.
+
+## When it does not work
+
+| what you see | why |
+|---|---|
+| an invite fails with `403` setting `mail` | a portal before 0.5.4 set the address in a second call; the onboarding account may create but not modify |
+| an invite fails with `404` adding to a group | the group is not managed by `portal-onboarding`: `group set-entry-manager portal-onboarding` |
+| `Item not found` setting the login page's name or logo | those are system settings only `admin` may change, not `idm_admin` - or the CLI and server versions differ |
+| accounts are made but nobody is mailed | `site.yaml` has no `mail.from`, or gdo is not running on the host |
+| someone responsible never got a mail | they already had an account (they are only added to `RESPONSIBLE_GROUP`), or their `contact` is not an email address |
+| the first person was invited twice | it cannot be, per address and group; changing `SEED_ADMIN_EMAIL` or `SEED_GROUP` invites the new pair |
--
2.54.0
From 7a50ce77b2f55045f02792c6e9b35dee8cc83c60 Mon Sep 17 00:00:00 2001
From: Bendik Aagaard Lynghaug
Date: Tue, 29 Sep 2026 18:03:51 +0200
Subject: [PATCH 3/4] Docs: getting started says what is retried and what waits
for the next start
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
---
docs/getting-started.md | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 08de04e..e06cceb 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -104,8 +104,10 @@ At start, portal:
questions, with their link. With `RESPONSIBLE_GROUP` they all join
it; someone who already had an account is added without a mail.
-Kanidm may still be starting beside portal, so a failed attempt is
-tried again, a minute apart. The log says what happened, per person.
+Kanidm may still be starting beside portal, so the first person's
+invite is tried again a minute apart, up to twenty times. A responsible
+person who could not be set up is left for the next start. The log says
+what happened, per person.
## 5. The rest come in from the desks
@@ -113,7 +115,6 @@ From here nobody touches Kanidm:
- **At a desk**, whoever may invite fills in a name and an address.
Portal makes the account, adds them to the group, and mails the link.
- The invites sent are listed on that desk.
- **A decision can be the invitation.** A state with `grants: group`
in `aggregates.yaml` puts the person a case is about into that group
when the case arrives there - an approved committee application that
--
2.54.0
From ef4789f9b28e912d4580ea56ca84e8026ecf81cd Mon Sep 17 00:00:00 2001
From: Bendik Aagaard Lynghaug
Date: Tue, 29 Sep 2026 21:00:10 +0200
Subject: [PATCH 4/4] Invites and suggestions are cases; the responsible follow
the content
Five things the first cut of accounts-from-content left open.
The first person and the responsible people are set up side by side,
and both try again a minute apart: a seed that waited twenty minutes on
Kanidm used to hold everyone else up, and a responsible person who
failed waited for the next restart.
The responsible are gone over again whenever the content changes.
watch_for_reload publishes portal.content.reloaded once new content is
in place, and seed.rs listens.
Being responsible ends when the content stops saying so: someone no
page names any more leaves RESPONSIBLE_GROUP in the same pass, if
portal is who put them there (Invited.already_member). The account
stays.
An invite keeps itself current. portal_invites has an expired state;
signing in closes a person's invites, a sweep moves the ones whose link
ran out, and a desk's move from expired back to open makes a fresh link
and mails it. Invites made at start are on file with the desks'.
A suggested edit is a case in the built-in portal_edit_suggestions
bucket, the person who made it is mailed at each move, and the card
lists what they sent and where it stands.
Found on the way, against Kanidm 1.11.2: a link's end comes as seconds
since the epoch, which portal did not read, so invite mails said
"until" and nothing; and a mail search answered with a 5xx was taken
for a refusal.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
---
CHANGELOG.md | 8 +-
docs/getting-started.md | 70 ++++++-
docs/operations.md | 2 +-
src/answers.rs | 6 +-
src/app.rs | 154 +++++++++++++--
src/content/gitea.rs | 8 +
src/content/validate.rs | 21 +-
src/i18n.rs | 15 ++
src/mail.rs | 140 ++++++++++++-
src/main.rs | 6 +-
src/people.rs | 423 ++++++++++++++++++++++++++++++++++++++--
src/seed.rs | 217 ++++++++++++++++++---
src/server/oidc.rs | 4 +
style/main.css | 39 ++++
14 files changed, 1025 insertions(+), 88 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 53420b9..01530df 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,8 +6,12 @@ content repos pin the tag they run (see the end of this file).
## Unreleased
-- Accounts the content mandates, made at start (`src/seed.rs`). Every person a page names as `responsible` gets a Kanidm account if they have none, and a mail in the site's language saying they are listed as responsible for asking that question, with the link to sign in; `RESPONSIBLE_GROUP` puts them all in one group (people who already had an account are added without a mail). `SEED_ADMIN_EMAIL` (and `SEED_ADMIN_NAME`, `SEED_GROUP`) invites the first person into the site's most privileged group - the group whose desk may invite into the most groups - so a site with its own Kanidm has someone to invite everyone else. Both happen once per address and group (`portal_seed`), and retry while Kanidm comes up.
-- A page's responsible person, signed in, may suggest a change to it: a card at the foot of their own pages, checked on the server against the page's `responsible.contact`, kept in `portal_edit_suggestions`, published on `portal.edits.suggested`, and mailed to the site's inbox with them as reply-to. The session now carries the signed-in person's email.
+- Accounts the content mandates, made at start (`src/seed.rs`). Every person a page names as `responsible` gets a Kanidm account if they have none, and a mail in the site's language saying they are listed as responsible for asking that question, with the link to sign in; `RESPONSIBLE_GROUP` puts them all in one group (people who already had an account are added without a mail). `SEED_ADMIN_EMAIL` (and `SEED_ADMIN_NAME`, `SEED_GROUP`) invites the first person into the site's most privileged group - the group whose desk may invite into the most groups - so a site with its own Kanidm has someone to invite everyone else. Both happen once per address and group (`portal_seed`), side by side, and each tries again a minute apart, twenty times, while Kanidm comes up. The responsible are gone over again whenever the content changes (`portal.content.reloaded`, published once new content is in place), so a new page's person is set up without a restart - and someone the content has stopped naming leaves `RESPONSIBLE_GROUP` in the same pass, if portal is who put them there.
+- An invite is a case that keeps itself current (`portal_invites`: `open`, `expired`, `done`). Signing in closes a person's invites; a sweep every ten minutes moves one whose link ran out unused to `expired`, and takes the link off the record. From `expired` a desk may send it again: the move back to `open` makes a fresh link and mails it. Content offers that with `{ from: expired, to: open }` on the list of invites. The invites portal makes itself at start are on file with the rest.
+- Fixed: an invite mail said the link worked "until" and then nothing. Kanidm 1.11 gives a link's end as seconds since the epoch, and portal read only the older RFC 3339 string.
+- Fixed: a search by mail that Kanidm failed to answer (a 5xx) was read as "not permitted", and the invite went on to match by username - which could give someone with an account under another name a second one. Only a refusal is read that way now.
+- A page's responsible person, signed in, may suggest a change to it: a card at the foot of their own pages, checked on the server against the page's `responsible.contact`, published on `portal.edits.suggested`, and mailed to the site's inbox with them as reply-to. It is a case in the built-in `portal_edit_suggestions` bucket - `open`, `discussed`, `done` or `declined` - so a desk that lists the bucket takes it up and settles it, the person who made it is mailed at each move, and the card shows them what became of what they sent before. The session now carries the signed-in person's email.
+- The mail to someone named responsible names the first three questions and counts the rest; on a site where one address answers for every page it used to list them all.
- Fixed: an invite of someone new failed on a Kanidm set up with the onboarding account alone. Portal created the person, then set their address in a second call, and `idm_people_on_boarding` may create but not modify: 403. The address now goes in with the person.
## 0.5.3 (2026-09-29)
diff --git a/docs/getting-started.md b/docs/getting-started.md
index e06cceb..8b7fe85 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -104,11 +104,18 @@ At start, portal:
questions, with their link. With `RESPONSIBLE_GROUP` they all join
it; someone who already had an account is added without a mail.
-Kanidm may still be starting beside portal, so the first person's
-invite is tried again a minute apart, up to twenty times. A responsible
-person who could not be set up is left for the next start. The log says
+The two run side by side. Kanidm may still be starting beside portal,
+so each is tried again a minute apart, up to twenty times. The log says
what happened, per person.
+The second happens again **whenever the content changes**, so a new
+page naming a new person sets them up without a restart. And it works
+both ways: someone the content has stopped naming leaves
+`RESPONSIBLE_GROUP` in the same pass, and with it whatever else that
+group opens. Only if portal is who put them there - a person who was in
+the group before is left in it - and their account stays, since it is
+theirs.
+
## 5. The rest come in from the desks
From here nobody touches Kanidm:
@@ -119,16 +126,60 @@ From here nobody touches Kanidm:
in `aggregates.yaml` puts the person a case is about into that group
when the case arrives there - an approved committee application that
*is* the membership.
-- **A new page with a new responsible person** gets them an account at
- the next start.
+- **A new page with a new responsible person** gets them an account
+ when the page goes live.
+
+### An invite that nobody used
+
+A link lives a day. Every invite - a desk's, and the ones portal made
+at start - is a case in `portal_invites`, and keeps itself current:
+
+| state | means | how it gets there |
+|---|---|---|
+| `open` | the link is waiting | the invite was sent, or sent again |
+| `done` | the person is in | they signed in to the site; or a desk says so |
+| `expired` | the link ran out unused | a sweep, every ten minutes |
+
+A desk that lists the bucket offers the way back with a transition from
+`expired` to `open`. Pressing it makes a fresh link and mails it:
+
+```yaml
+resource:
+ source: { kind: kv, bucket: portal_invites }
+ requires_group: styret
+ transitions:
+ - { from: open, to: done, label: Is in }
+ - { from: expired, to: open, label: Send again }
+ - { from: expired, to: done, label: Is in }
+```
## 6. What the people who ask the questions can do
Signed in, a person a page names as responsible sees a card at the
foot of their own pages to suggest a change. The server checks it is
-really them; the suggestion is kept (`portal_edit_suggestions`),
-announced (`portal.edits.suggested`), and mailed to the site's inbox
-with them as reply-to, so answering it answers them.
+really them, and the suggestion is mailed to the site's inbox with them
+as reply-to, so answering it answers them.
+
+It is also a case, in the built-in `portal_edit_suggestions` bucket, so
+it does not depend on somebody reading that inbox. Give a desk the
+list, and whoever sits there takes it up and settles it:
+
+```yaml
+resource:
+ source: { kind: kv, bucket: portal_edit_suggestions }
+ requires_group: styret
+ transitions:
+ - { from: open, to: discussed, label: Looking at it }
+ - { from: open, to: done, label: Changed }
+ - { from: open, to: declined, label: Will not change }
+ - { from: discussed, to: done, label: Changed }
+ - { from: discussed, to: declined, label: Will not change }
+```
+
+The person who suggested it is mailed at each move, in the site's
+language, and the card on their page lists what they have sent and
+where each one stands. A content repo that declares the bucket itself
+in `aggregates.yaml`, with a `mail:` on a state, sends that instead.
Their group can also be the way into other tools. The vel's
`redaktor` group signs in to the project Gitea with the same account:
@@ -143,4 +194,7 @@ a second OAuth2 client scoped to that group alone.
| `Item not found` setting the login page's name or logo | those are system settings only `admin` may change, not `idm_admin` - or the CLI and server versions differ |
| accounts are made but nobody is mailed | `site.yaml` has no `mail.from`, or gdo is not running on the host |
| someone responsible never got a mail | they already had an account (they are only added to `RESPONSIBLE_GROUP`), or their `contact` is not an email address |
+| an invite stays `open` though the person has set a passkey | Kanidm does not show a passkey to the onboarding account; it becomes `done` when they sign in to the site |
+| "send again" is not on the desk | the list has no `{ from: expired, to: open }` transition, or the content declares `portal_invites` itself without an `expired` state |
+| someone left a page and is still in `RESPONSIBLE_GROUP` | they were in the group before portal added them, so it is not portal's to undo |
| the first person was invited twice | it cannot be, per address and group; changing `SEED_ADMIN_EMAIL` or `SEED_GROUP` invites the new pair |
diff --git a/docs/operations.md b/docs/operations.md
index 9b2b497..67888cf 100644
--- a/docs/operations.md
+++ b/docs/operations.md
@@ -71,7 +71,7 @@ values.
| `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET` | sign-in |
| `KANIDM_API_TOKEN` | invites and `grants:`; unset, neither happens |
| `SEED_ADMIN_EMAIL`, `SEED_ADMIN_NAME`, `SEED_GROUP` | the first person in, invited once at start into the most privileged group (or `SEED_GROUP`) |
-| `RESPONSIBLE_GROUP` | the group every person a page names as `responsible` joins; they get an account at start either way |
+| `RESPONSIBLE_GROUP` | the group every person a page names as `responsible` joins, at start and whenever the content changes, and leaves when no page names them any more; they get an account either way |
| `PUBLIC_URL`, `SITE_NAME` | what the site calls itself in links and mail |
| `GITEA_API_TOKEN` | authenticated resource pulls |
| `AUTOMATION_READ_TOKEN` | the automation KV read endpoint |
diff --git a/src/answers.rs b/src/answers.rs
index c8e3d5c..ade8d5c 100644
--- a/src/answers.rs
+++ b/src/answers.rs
@@ -167,7 +167,7 @@ pub async fn transition_answers(
/// transition retries. Keeps "zap the whole event stream" a safe
/// operation instead of one that strands every pre-wipe record.
#[cfg(feature = "ssr")]
-async fn transition_or_reseed(
+pub(crate) async fn transition_or_reseed(
js: &async_nats::jetstream::Context,
schema: &crate::aggregates::AggregateSchema,
item_id: &str,
@@ -291,6 +291,10 @@ async fn apply_transition(
.map_err(|e| ServerFnError::new(e.to_string()))?;
crate::mail::on_state(state, bucket, &item.item_id, &item.to, &answer.responses).await;
crate::people::on_state(state, bucket, &item.item_id, &item.to, &answer.responses).await;
+ // An expired invite sent again: the move back is the asking.
+ if bucket == crate::people::INVITES_BUCKET && item.to == OPEN_STATE {
+ crate::people::on_reopened(state, &item.item_id).await;
+ }
// The decision extends the DAG as a child of the answer's own
// submission hash, published like any other answer.
diff --git a/src/app.rs b/src/app.rs
index 7c2ff68..80750a8 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -633,13 +633,41 @@ pub fn is_responsible(user: Option<&User>, contact: &str) -> bool {
user.and_then(|u| u.email.as_deref()).is_some_and(|e| !contact.is_empty() && e.trim().to_lowercase() == contact)
}
+/// One suggestion as the person who made it sees it: what they wrote
+/// and where it stands.
+#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
+pub struct MySuggestion {
+ pub id: String,
+ pub text: String,
+ pub state: String,
+ pub submitted_ms: i64,
+}
+
+/// The translation key for where a suggestion stands. A state the
+/// content added to the bucket's graph has no words here, and shows as
+/// itself.
+fn suggestion_state_key(state: &str) -> Option<&'static str> {
+ match state {
+ "open" => Some("suggestion_open"),
+ "discussed" => Some("suggestion_discussed"),
+ "done" => Some("suggestion_done"),
+ "declined" => Some("suggestion_declined"),
+ _ => None,
+ }
+}
+
/// A change suggested by the person responsible for the page: one
-/// field, sent to the site's inbox. Shown only to them.
+/// field, which becomes a case for whoever keeps the site, and below
+/// it what became of the ones sent before. Shown only to them.
#[component]
fn SuggestEdit(question_id: String) -> impl IntoView {
let send = ServerAction::::new();
let text = NodeRef::::new();
let sent = move || send.value().get().is_some_and(|r| r.is_ok());
+ let mine = {
+ let question_id = question_id.clone();
+ Resource::new(move || send.version().get(), move |_| my_suggestions(question_id.clone()))
+ };
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let value = text.get().map(|t| t.value()).unwrap_or_default();
@@ -662,6 +690,30 @@ fn SuggestEdit(question_id: String) -> impl IntoView {
>
+ })
+ }}
+
}
}
@@ -2483,11 +2535,15 @@ pub struct SubmitResult {
/// A change to a page, suggested by the person it names as responsible.
/// Checked here, not trusted from the page: the session's address must
-/// be the page's responsible contact. Kept in `portal_edit_suggestions`,
-/// announced on `portal.edits.suggested`, and mailed to the site's inbox.
+/// be the page's responsible contact. It becomes a case in
+/// `portal_edit_suggestions` (open, discussed, done or declined, see
+/// the built-in aggregates), is announced like any answer and on
+/// `portal.edits.suggested`, and is mailed to the site's inbox.
#[server(endpoint = "suggest_page_edit")]
pub async fn suggest_page_edit(question_id: String, text: String) -> Result<(), ServerFnError> {
use crate::auth::{User, SESSION_USER_KEY};
+ use crate::chain::hash_node;
+ use crate::events::{emit_answer_submitted, AnswerSubmitted};
use crate::server::AppState;
let state = expect_context::();
@@ -2505,25 +2561,91 @@ pub async fn suggest_page_edit(question_id: String, text: String) -> Result<(),
let user = user.expect("is_responsible needs a user");
let email = user.email.clone().unwrap_or_default();
let now_ms = chrono::Utc::now().timestamp_millis();
- let record = serde_json::json!({
- "question_id": question_id, "question": question.name,
- "by": user.username, "email": email, "text": text, "submitted_ms": now_ms,
+ // The fields a desk shows and a mail may name: who, what, and
+ // which page. `email` is where word of what became of it goes.
+ let responses = serde_json::json!({
+ "name": user.display_name, "email": email, "by": user.username,
+ "page": question_id, "question": question.name, "text": text,
});
- let store = match state.jetstream.get_key_value("portal_edit_suggestions").await {
- Ok(store) => store,
- Err(_) => state
- .jetstream
- .create_key_value(async_nats::jetstream::kv::Config { bucket: "portal_edit_suggestions".to_string(), ..Default::default() })
- .await
- .map_err(|e| ServerFnError::new(e.to_string()))?,
+ let id = hash_node(&question_id, &[], &responses, now_ms);
+ crate::answers::store_answer(
+ &state.jetstream,
+ &state.aggregates.load(),
+ crate::content::EDIT_SUGGESTIONS_BUCKET,
+ id.clone(),
+ &question_id,
+ SUGGESTION_ALTERNATIVE,
+ &responses,
+ now_ms,
+ )
+ .await
+ .map_err(|e| ServerFnError::new(e.to_string()))?;
+ let event = AnswerSubmitted {
+ chain_hash: id,
+ parent_hashes: Vec::new(),
+ question_id: question_id.clone(),
+ alternative: SUGGESTION_ALTERNATIVE.to_string(),
+ responses: responses.clone(),
+ timestamp_ms: now_ms,
};
- let bytes = serde_json::to_vec(&record).map_err(|e| ServerFnError::new(e.to_string()))?;
- store.put(format!("{now_ms}-{}", user.username.replace(|c: char| !c.is_ascii_alphanumeric(), "_")), bytes.clone().into()).await.map_err(|e| ServerFnError::new(e.to_string()))?;
- let _ = state.nats.publish("portal.edits.suggested", bytes.into()).await;
+ if let Err(e) = emit_answer_submitted(&state.nats, &event).await {
+ tracing::warn!(question_id, "the suggestion is on file but was not announced: {e}");
+ }
+ if let Ok(bytes) = serde_json::to_vec(&responses) {
+ let _ = state.nats.publish("portal.edits.suggested", bytes.into()).await;
+ }
crate::mail::on_edit_suggestion(&state, &question_id, &question.name, &user.display_name, &email, &text).await;
Ok(())
}
+/// What a suggestion is recorded under, where an answer has the name
+/// of the alternative that was chosen.
+#[cfg(feature = "ssr")]
+pub const SUGGESTION_ALTERNATIVE: &str = "Suggested change";
+
+/// The signed-in person's own suggestions for a page, newest first,
+/// with where each one stands. Theirs only: matched on the session's
+/// address, which is what a suggestion was filed under.
+#[server(endpoint = "my_suggestions")]
+pub async fn my_suggestions(question_id: String) -> Result, ServerFnError> {
+ use crate::auth::{User, SESSION_USER_KEY};
+ use crate::server::AppState;
+
+ let state = expect_context::();
+ let session: tower_sessions::Session = leptos_axum::extract().await?;
+ let user = session.get::(SESSION_USER_KEY).await.map_err(|e| ServerFnError::new(e.to_string()))?;
+ let Some(email) = user.and_then(|u| u.email).map(|e| e.trim().to_lowercase()).filter(|e| !e.is_empty()) else {
+ return Ok(Vec::new());
+ };
+ // Made on first write: nobody has suggested anything yet.
+ let Ok(store) = state.jetstream.get_key_value(crate::content::EDIT_SUGGESTIONS_BUCKET).await else {
+ return Ok(Vec::new());
+ };
+ let keys: Vec = {
+ use futures::TryStreamExt;
+ match store.keys().await {
+ Ok(keys) => keys.try_collect().await.unwrap_or_default(),
+ Err(_) => Vec::new(),
+ }
+ };
+ let mut mine = Vec::new();
+ for key in keys {
+ let Ok(Some(bytes)) = store.get(&key).await else { continue };
+ let Ok(answer) = serde_json::from_slice::(&bytes) else { continue };
+ let theirs = answer.responses.get("email").and_then(|v| v.as_str()).is_some_and(|e| e.trim().to_lowercase() == email);
+ if theirs && answer.question_id == question_id {
+ mine.push(MySuggestion {
+ id: answer.id,
+ text: answer.responses.get("text").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
+ state: answer.state,
+ submitted_ms: answer.submitted_ms,
+ });
+ }
+ }
+ mine.sort_by_key(|s| std::cmp::Reverse(s.submitted_ms));
+ Ok(mine)
+}
+
#[server(endpoint = "submit_answer")]
pub async fn submit_answer(
question_id: String,
diff --git a/src/content/gitea.rs b/src/content/gitea.rs
index 60c99b1..d56b889 100644
--- a/src/content/gitea.rs
+++ b/src/content/gitea.rs
@@ -171,6 +171,13 @@ pub async fn load_questions_from_gitea(
#[cfg(feature = "ssr")]
pub const CONTENT_RELOAD_SUBJECT: &str = "portal.content.reload";
+/// Published by `watch_for_reload` once new content is in place, for
+/// whatever acts on what the content says rather than serving it: the
+/// accounts it mandates (`seed.rs`). No payload; the content is read
+/// from where it always is.
+#[cfg(feature = "ssr")]
+pub const CONTENT_RELOADED_SUBJECT: &str = "portal.content.reloaded";
+
/// Runs for the life of the process: re-fetches `repo_url`/`branch` and
/// atomically swaps both `questions` and `aggregates` on every
/// `CONTENT_RELOAD_SUBJECT` message - the two only ever swap together,
@@ -233,6 +240,7 @@ pub async fn watch_for_reload(
aggregates.store(std::sync::Arc::new(loaded_aggregates));
site.store(std::sync::Arc::new(loaded_site));
tracing::info!(count, "reloaded content");
+ let _ = nats.publish(CONTENT_RELOADED_SUBJECT, "".into()).await;
}
Err(e) => {
tracing::error!(error = %e, "content reload failed, keeping last-good content");
diff --git a/src/content/validate.rs b/src/content/validate.rs
index a9f8a04..30b7f39 100644
--- a/src/content/validate.rs
+++ b/src/content/validate.rs
@@ -36,6 +36,12 @@ pub fn validate_relay_url(relay: &str) -> anyhow::Result<()> {
/// runtime's sweeper (`announce.rs`) is what moves the records.
pub const EVENTS_BUCKET: &str = "portal_events";
+/// Bucket for changes suggested by the people the content names as
+/// responsible (`app::suggest_page_edit`): a case like any other, so a
+/// suggestion is taken up, carried out or turned down by someone at a
+/// desk, and the person who made it hears which.
+pub const EDIT_SUGGESTIONS_BUCKET: &str = "portal_edit_suggestions";
+
#[cfg(feature = "ssr")]
const BUILTIN_AGGREGATES_YAML: &str = "\
aggregates:
@@ -53,9 +59,22 @@ aggregates:
initial: open
states:
open: { event: invited }
+ expired: { event: expired }
done: { event: done }
transitions:
- open: [done]
+ open: [done, expired]
+ expired: [open, done]
+ - bucket: portal_edit_suggestions
+ attended_by: whoever keeps the content; every suggestion is mailed to the site's inbox too
+ initial: open
+ states:
+ open: { event: suggested }
+ discussed: { event: discussed }
+ done: { event: done }
+ declined: { event: declined }
+ transitions:
+ open: [discussed, done, declined]
+ discussed: [done, declined]
";
/// Adds the runtime's own state graphs to a content repo's. Content
diff --git a/src/i18n.rs b/src/i18n.rs
index 84718f4..d74b885 100644
--- a/src/i18n.rs
+++ b/src/i18n.rs
@@ -38,6 +38,11 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
("en", "suggest_edit_help") => "You are listed as responsible for this question. Say what should change, and it goes to the people who keep the site.",
("en", "suggest_edit_send") => "Send the suggestion",
("en", "suggest_edit_sent") => "Thank you - your suggestion is sent.",
+ ("en", "suggestions_yours") => "What you have suggested here",
+ ("en", "suggestion_open") => "Sent",
+ ("en", "suggestion_discussed") => "Being looked at",
+ ("en", "suggestion_done") => "Done",
+ ("en", "suggestion_declined") => "Not done",
("en", "the_start") => "the start",
("no", "also_worth_asking") => "Også verdt å spørre",
@@ -62,6 +67,11 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
("no", "suggest_edit_help") => "Du står oppført som ansvarlig for dette spørsmålet. Skriv hva som bør endres, så går det til dem som har siden.",
("no", "suggest_edit_send") => "Send forslaget",
("no", "suggest_edit_sent") => "Takk - forslaget er sendt.",
+ ("no", "suggestions_yours") => "Det du har foreslått her",
+ ("no", "suggestion_open") => "Sendt",
+ ("no", "suggestion_discussed") => "Blir sett på",
+ ("no", "suggestion_done") => "Gjort",
+ ("no", "suggestion_declined") => "Ikke gjort",
("no", "the_start") => "starten",
_ => return None,
@@ -101,6 +111,11 @@ mod tests {
"suggest_edit_help",
"suggest_edit_send",
"suggest_edit_sent",
+ "suggestions_yours",
+ "suggestion_open",
+ "suggestion_discussed",
+ "suggestion_done",
+ "suggestion_declined",
] {
assert!(lookup("en", key).is_some(), "en missing {key}");
assert!(lookup("no", key).is_some(), "no missing {key}");
diff --git a/src/mail.rs b/src/mail.rs
index 2fa11ac..c7eb3be 100644
--- a/src/mail.rs
+++ b/src/mail.rs
@@ -265,6 +265,11 @@ async fn publish(js: &async_nats::jetstream::Context, mail: &Outgoing) -> anyhow
/// failed request.
pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, to_state: &str, responses: &serde_json::Value) {
let Some(spec) = state.aggregates.load().get(bucket).and_then(|s| s.mail_for(to_state)).cloned() else {
+ // A suggestion has its own words for this unless the content
+ // gave the state a mail, which then is the one sent.
+ if bucket == crate::content::EDIT_SUGGESTIONS_BUCKET {
+ on_suggestion_state(state, id, to_state, responses).await;
+ }
return;
};
let site = state.site.load();
@@ -301,6 +306,33 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
}
}
+/// The questions a person answers for, as a mail names them: each in
+/// quotes, and past the first three only how many more - someone who
+/// answers for a whole site is not read its table of contents.
+pub fn questions_listed(questions: &[String], norwegian: bool) -> String {
+ let quote = |q: &String| if norwegian { format!("«{q}»") } else { format!("\u{201c}{q}\u{201d}") };
+ let and = if norwegian { "og" } else { "and" };
+ match questions {
+ [] => String::new(),
+ [one] => quote(one),
+ few if few.len() <= 3 => {
+ let quoted: Vec = few.iter().map(quote).collect();
+ format!("{} {and} {}", quoted[..quoted.len() - 1].join(", "), quoted[quoted.len() - 1])
+ }
+ many => {
+ let quoted: Vec = many[..3].iter().map(quote).collect();
+ let more = many.len() - 3;
+ let others = match (norwegian, more) {
+ (true, 1) => "ett spørsmål til".to_string(),
+ (true, n) => format!("{n} spørsmål til"),
+ (false, 1) => "one more question".to_string(),
+ (false, n) => format!("{n} more questions"),
+ };
+ format!("{} {and} {others}", quoted.join(", "))
+ }
+ }
+}
+
/// The mail a person gets when the content names them responsible for
/// a question and they had no account: who they are to the site (the
/// person listed as asking that question), and the link that lets them
@@ -312,12 +344,7 @@ pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::pe
return;
};
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
- let quoted: Vec = questions.iter().map(|q| if norwegian { format!("«{q}»") } else { format!("\u{201c}{q}\u{201d}") }).collect();
- let list = match quoted.len() {
- 0 => String::new(),
- 1 => quoted[0].clone(),
- n => format!("{} {} {}", quoted[..n - 1].join(", "), if norwegian { "og" } else { "and" }, quoted[n - 1]),
- };
+ let list = questions_listed(questions, norwegian);
let (subject, body) = if norwegian {
(
"Du står som ansvarlig for et spørsmål hos {site_name}".to_string(),
@@ -360,6 +387,81 @@ pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::pe
}
}
+/// What the person who suggested a change hears when it moves, as
+/// subject and body; `None` for a state that says nothing new to them
+/// (it arriving, or one the content added). `{name}`, `{question}`,
+/// `{page}`, `{quoted}`.
+pub fn suggestion_words(to_state: &str, norwegian: bool) -> Option<(&'static str, &'static str)> {
+ Some(match (to_state, norwegian) {
+ ("discussed", true) => (
+ "Forslaget ditt om «{question}» blir sett på",
+ "Hei {name},\n\nForslaget ditt til endring på {page} er tatt opp, og blir sett på nå. Du får beskjed når det er avgjort.\n\nDu skrev:\n\n{quoted}",
+ ),
+ ("done", true) => (
+ "Endringen du foreslo i «{question}» er gjort",
+ "Hei {name},\n\nEndringen du foreslo på {page} er gjort. Takk for at du sa fra.\n\nDu skrev:\n\n{quoted}",
+ ),
+ ("declined", true) => (
+ "Forslaget ditt om «{question}» blir ikke gjort",
+ "Hei {name},\n\nForslaget ditt til endring på {page} blir ikke gjort slik det står. Svar på denne e-posten om du vil vite hvorfor, eller vil foreslå det på en annen måte.\n\nDu skrev:\n\n{quoted}",
+ ),
+ ("discussed", false) => (
+ "Your suggestion about \u{201c}{question}\u{201d} is being looked at",
+ "Hello {name},\n\nYour suggested change to {page} has been taken up and is being looked at. You will hear when it is settled.\n\nYou wrote:\n\n{quoted}",
+ ),
+ ("done", false) => (
+ "The change you suggested to \u{201c}{question}\u{201d} is made",
+ "Hello {name},\n\nThe change you suggested to {page} is made. Thank you for saying so.\n\nYou wrote:\n\n{quoted}",
+ ),
+ ("declined", false) => (
+ "Your suggestion about \u{201c}{question}\u{201d} will not be made",
+ "Hello {name},\n\nYour suggested change to {page} will not be made as it stands. Answer this mail if you want to know why, or to suggest it another way.\n\nYou wrote:\n\n{quoted}",
+ ),
+ _ => return None,
+ })
+}
+
+/// A suggestion moved: tell whoever made it. Replies go to the site's
+/// inbox, which is who decided.
+async fn on_suggestion_state(state: &crate::server::AppState, id: &str, to_state: &str, responses: &serde_json::Value) {
+ let site = state.site.load();
+ let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
+ let Some((subject, body)) = suggestion_words(to_state, norwegian) else {
+ return;
+ };
+ let Some(cfg) = &site.mail else {
+ tracing::warn!(id, to_state, "a suggestion moved, but site.yaml has no `mail.from` - whoever made it was not told");
+ return;
+ };
+ let field = |name: &str| responses.get(name).and_then(|v| v.as_str()).unwrap_or_default().trim().to_string();
+ let to = field("email");
+ if !to.contains('@') {
+ tracing::warn!(id, to_state, "a suggestion moved, but the record carries no email - not sent");
+ return;
+ }
+ let quoted: String = field("text").lines().map(|l| format!("> {l}")).collect::>().join("\n");
+ let mut vars = serde_json::Map::new();
+ vars.insert("name".into(), field("name").into());
+ vars.insert("question".into(), field("question").into());
+ vars.insert("page".into(), format!("{}{}", public_url(), field("page")).into());
+ vars.insert("quoted".into(), quoted.into());
+ let text = render(body, &vars);
+ let mail = Outgoing {
+ v: CONTRACT,
+ id: format!("{}:{id}:{to_state}", crate::content::EDIT_SUGGESTIONS_BUCKET),
+ site: site_name(&site),
+ from: cfg.from.clone(),
+ reply_to: cfg.reply_to.clone(),
+ to,
+ subject: render(subject, &vars),
+ html: to_html(&text),
+ text,
+ };
+ if let Err(e) = publish(&state.jetstream, &mail).await {
+ tracing::error!(id, to_state, "publishing the suggestion mail failed: {e}");
+ }
+}
+
/// A change suggested by the person responsible for a page, to the
/// site's own inbox (`mail.reply_to`, else `mail.from`), with the
/// suggester as reply-to so answering the mail answers them.
@@ -460,6 +562,32 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
mod tests {
use super::*;
+ #[test]
+ fn a_mail_names_three_questions_and_counts_the_rest() {
+ let q = |n: usize| (1..=n).map(|i| format!("Q{i}?")).collect::>();
+ assert_eq!(questions_listed(&q(0), true), "");
+ assert_eq!(questions_listed(&q(1), true), "«Q1?»");
+ assert_eq!(questions_listed(&q(3), true), "«Q1?», «Q2?» og «Q3?»");
+ assert_eq!(questions_listed(&q(4), true), "«Q1?», «Q2?», «Q3?» og ett spørsmål til");
+ assert_eq!(questions_listed(&q(17), true), "«Q1?», «Q2?», «Q3?» og 14 spørsmål til");
+ assert_eq!(questions_listed(&q(2), false), "\u{201c}Q1?\u{201d} and \u{201c}Q2?\u{201d}");
+ assert_eq!(questions_listed(&q(5), false), "\u{201c}Q1?\u{201d}, \u{201c}Q2?\u{201d}, \u{201c}Q3?\u{201d} and 2 more questions");
+ }
+
+ #[test]
+ fn a_suggestion_says_something_each_time_it_moves_and_nothing_on_arrival() {
+ for norwegian in [true, false] {
+ for state in ["discussed", "done", "declined"] {
+ let (subject, body) = suggestion_words(state, norwegian).expect(state);
+ assert!(subject.contains("{question}"), "{state}: the subject names the question");
+ assert!(body.contains("{page}") && body.contains("{quoted}"), "{state}: the body says where and what");
+ }
+ // Arriving is told to the inbox, not back to who sent it.
+ assert!(suggestion_words("open", norwegian).is_none());
+ assert!(suggestion_words("a_state_the_content_added", norwegian).is_none());
+ }
+ }
+
#[test]
fn templates_take_fields_and_leave_stray_braces_alone() {
let responses = serde_json::json!({ "name": "Kari", "email": " kari@x.no ", "count": 3, "hva": null });
diff --git a/src/main.rs b/src/main.rs
index 80c328d..2359701 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -108,8 +108,12 @@ async fn main() -> anyhow::Result<()> {
// windows (see announce.rs).
tokio::spawn(portal::announce::run(state.clone()));
tokio::spawn(portal::deadlines::run(state.clone()));
- // The first person in, when SEED_ADMIN_EMAIL names one (seed.rs).
+ // The accounts the content mandates: the first person in, and
+ // everyone a page names as responsible, at start and whenever the
+ // content changes (seed.rs).
tokio::spawn(portal::seed::run(state.clone()));
+ // Invites whose link ran out, or whose person is in (people.rs).
+ tokio::spawn(portal::people::run(state.clone()));
// Replies to case mail, matched to their record by whatever reads
// the mailbox (uhhm/gdo over JMAP) and turned into notes here. A
diff --git a/src/people.rs b/src/people.rs
index 977ae47..1696555 100644
--- a/src/people.rs
+++ b/src/people.rs
@@ -8,14 +8,35 @@
//! Talks to Kanidm's REST API with a service-account token
//! (`KANIDM_API_TOKEN`, read-write) through the bounded client. The
//! service account belongs to `idm_people_on_boarding`, which may
-//! create persons and issue first-credential resets and nothing else,
-//! so a leaked token cannot touch existing credentials or admins.
+//! create persons and issue credential resets for ordinary people and
+//! nothing else, so a leaked token cannot touch an admin.
+//!
+//! **An invite is a case.** Each one is a record in `portal_invites`:
+//! `open` while the link waits, `done` once the person is in, `expired`
+//! when the link ran out unused. Nobody has to keep that current by
+//! hand: signing in to the site closes a person's invites, and a sweep
+//! moves the ones whose link has run out (`sweep_invites`). From
+//! `expired` a desk may send it again - the move back to `open` makes a
+//! fresh link and mails it (`on_reopened`).
#![cfg(feature = "ssr")]
use serde::{Deserialize, Serialize};
pub const INVITES_BUCKET: &str = "portal_invites";
pub const INVITED_SUBJECT: &str = "portal.people.invited";
+/// The states an invite moves between, besides the schema's initial
+/// `open` (see `BUILTIN_AGGREGATES_YAML`).
+pub const INVITE_EXPIRED: &str = "expired";
+pub const INVITE_DONE: &str = "done";
+/// What an invite made at start is recorded under, where a desk invite
+/// has the name of the alternative that made it.
+pub const SEED_ALTERNATIVE: &str = "Invited at start";
+/// Stamped on a move nobody pressed, in place of a username.
+pub const BY: &str = "portal";
+
+/// How long a link lives: the longest Kanidm allows, since it travels
+/// by email and is read when the person gets to it.
+const LINK_SECONDS: i64 = 86_400;
/// What an invite produced: the account, and the link that sets it up.
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -29,6 +50,42 @@ pub struct Invited {
pub reset_link: Option,
pub link_expires_ms: Option,
pub created: bool,
+ /// They were in the group before this invite put them there, so
+ /// the membership is not portal's to take back.
+ #[serde(default)]
+ pub already_member: bool,
+}
+
+/// When a link runs out, from Kanidm's `expiry_time`: seconds since
+/// the epoch in 1.11, an RFC 3339 string before that.
+fn expiry_ms(value: &serde_json::Value) -> Option {
+ match value {
+ serde_json::Value::Number(n) => n.as_i64().map(|s| s * 1000),
+ serde_json::Value::String(s) => chrono::DateTime::parse_from_rfc3339(s).ok().map(|t| t.timestamp_millis()),
+ _ => None,
+ }
+}
+
+/// A group's members as bare account names (`kari`, not
+/// `kari@id.example.no`). Kanidm answers an empty group with `null`.
+fn members_from(body: &str) -> Vec {
+ serde_json::from_str::