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));
}
}