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
+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]