A group may hold a page's responsibility
Test / test (pull_request) Successful in 56s

responsible: { name, contact, group } says the address is a group's
inbox, not a person: no account or mail is made for it, and any
signed-in member of the group gets the suggest-a-change card. A
board's shared address was onboarded as a person before this.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-30 08:17:27 +02:00
co-authored by Claude Fable 5.1
parent de65392917
commit 014594283a
6 changed files with 65 additions and 19 deletions
+4
View File
@@ -4,6 +4,10 @@ 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).
## Unreleased
- A group may hold a page's responsibility: `responsible: { name, contact, group: styret }`. No account or mail for the inbox, and any signed-in member of the group gets the suggest-a-change card. Before this, a board's shared address was onboarded as a person.
## 0.5.5 (2026-09-30)
- One record answers another, from a list. `link` on a resource puts a link on every listed record to a page about it (`{id}`, `{field}`), a state mail's `to` may be a field of the record this one belongs to (`{notice.email}`, and `{notice.field}` in the text), and `value: "{user.email}"` fills a field from the session and hides it, so a signed-in person is not asked who they are. Together they make a board: a notice is listed with "answer this", the answer page's form carries the notice's id, and the mail goes to whoever posted it. The lint requires the record field the mail reaches through.
+4 -1
View File
@@ -49,7 +49,10 @@ description: What we need from you.
qualifies: case_officers # a Kanidm group may open this page
requires_chain: /start # or: an answer they must already have given
followup: true # keep it out of the nav until earned
responsible: { name: Solveig, email: post@vestmark.example }
responsible: { name: Solveig, contact: post@vestmark.example }
# or a group's: no account is made for the inbox, and any signed-in
# member of the group may suggest a change to the page
responsible: { name: The board, contact: board@vestmark.example, group: board }
event: # this page announces an occasion
starts: 2026-10-14T18:00
duration: 2h
+1
View File
@@ -27,6 +27,7 @@ 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 |
| `responsible: { name, contact, group }` | a group answering for it | nothing: the inbox is not a person; the group's members have the desk, and each of them may suggest changes to the page |
| `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` |
+35 -16
View File
@@ -461,7 +461,7 @@ fn QuestionView(
{question
.responsible
.as_ref()
.filter(|r| is_responsible(user.as_ref(), &r.contact))
.filter(|r| is_responsible(user.as_ref(), r))
.map(|_| view! { <SuggestEdit question_id=question.id.clone()/> })}
{question
.responsible
@@ -611,12 +611,16 @@ 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)
/// Whether the signed-in person answers for a page: their Kanidm
/// address equals the page's `responsible.contact` (`mailto:` off,
/// case ignored), or they are in the group that holds it.
pub fn is_responsible(user: Option<&User>, responsible: &Responsible) -> bool {
let Some(user) = user else { return false };
if responsible.group.as_deref().is_some_and(|g| !g.is_empty() && user.groups.iter().any(|mine| mine == g)) {
return true;
}
let contact = responsible.contact.trim().trim_start_matches("mailto:").trim().to_lowercase();
user.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
@@ -2557,8 +2561,8 @@ pub async fn suggest_page_edit(question_id: String, text: String) -> Result<(),
}
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"));
if !is_responsible(user.as_ref(), &responsible) {
return Err(ServerFnError::new("only whoever is 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();
@@ -2901,17 +2905,32 @@ mod down_the_tree_tests {
mod responsible_tests {
use super::is_responsible;
use crate::auth::User;
use crate::content::Responsible;
fn user(email: Option<&str>) -> User {
User { sub: "s".into(), username: "kari".into(), display_name: "Kari".into(), groups: vec![], email: email.map(String::from) }
fn user(email: Option<&str>, groups: &[&str]) -> User {
User { sub: "s".into(), username: "kari".into(), display_name: "Kari".into(), groups: groups.iter().map(|g| g.to_string()).collect(), email: email.map(String::from) }
}
fn named(contact: &str, group: Option<&str>) -> Responsible {
Responsible { name: "R".into(), contact: contact.into(), group: group.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(""))), ""));
assert!(is_responsible(Some(&user(Some("kari@vel.no"), &[])), &named("mailto:Kari@Vel.no", None)));
assert!(!is_responsible(Some(&user(Some("ola@vel.no"), &[])), &named("kari@vel.no", None)));
assert!(!is_responsible(Some(&user(None, &[])), &named("kari@vel.no", None)));
assert!(!is_responsible(None, &named("kari@vel.no", None)));
assert!(!is_responsible(Some(&user(Some(""), &[])), &named("", None)));
}
#[test]
fn a_page_a_group_answers_for_is_every_members() {
let board = named("post@vel.no", Some("styret"));
assert!(is_responsible(Some(&user(Some("ola@vel.no"), &["styret"])), &board));
assert!(!is_responsible(Some(&user(Some("ola@vel.no"), &["trafikk"])), &board));
// The inbox itself, signed in, still counts.
assert!(is_responsible(Some(&user(Some("post@vel.no"), &[])), &board));
assert!(!is_responsible(None, &board));
}
}
+7
View File
@@ -296,6 +296,13 @@ pub struct Responsible {
/// A mailto address or plain email - deliberately just a string,
/// same as everywhere else content keeps contact info simple.
pub contact: String,
/// The group that holds the responsibility, when it is a group's
/// and not one person's: the board's inbox, not the board's
/// secretary. Then the address gets no account of its own (see
/// `seed.rs`), and any signed-in member of the group may suggest a
/// change to the page.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
}
/// Whether to render `question`'s alternatives or its sign-in gate for
+14 -2
View File
@@ -20,7 +20,9 @@
//!
//! **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 start, and again whenever the
//! question - unless `group` says it is a group's, in which case the
//! address is an inbox and nobody is made for it. A person gets an
//! account: at start, and again whenever the
//! content changes, 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,
@@ -77,7 +79,9 @@ pub fn seed_key(email: &str, group: &str) -> String {
/// 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.
/// questions they answer for, in page order. A page a group answers
/// for (`responsible.group`) names nobody: the board's inbox is not a
/// person, and its members have their accounts through the desk.
pub fn responsible_people(questions: &HashMap<String, Question>) -> BTreeMap<String, (String, Vec<String>)> {
let mut ids: Vec<&String> = questions.keys().collect();
ids.sort();
@@ -85,6 +89,9 @@ pub fn responsible_people(questions: &HashMap<String, Question>) -> BTreeMap<Str
for id in ids {
let q = &questions[id];
let Some(r) = &q.responsible else { continue };
if r.group.as_deref().is_some_and(|g| !g.trim().is_empty()) {
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()));
@@ -415,6 +422,11 @@ mod tests {
assert_eq!(people.keys().collect::<Vec<_>>(), ["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);
// A group's inbox is nobody: its members have the desk.
let mut held: Question = serde_yaml::from_str("name: \"Hva?\"\nresponsible: { name: Styret, contact: post@vel.no, group: styret }\nalternatives:\n - name: A\n").unwrap();
held.id = "/d".into();
qs.insert("/d".into(), held);
assert!(!responsible_people(&qs).contains_key("post@vel.no"));
}
#[test]