diff --git a/src/build.rs b/src/build.rs index 168584e..99b6b82 100644 --- a/src/build.rs +++ b/src/build.rs @@ -32,6 +32,13 @@ pub struct Plan { #[serde(default = "english")] pub language: String, pub contact: Contact, + /// Where the site keeps its people: `kanidm` (the default) makes an + /// account per invite and holds the groups; `memberships` keeps the + /// groups in portal (its `portal_memberships` bucket) and lets any + /// sign-in provider say who someone is - a person is then their + /// phone number first, so invite forms ask for one. + #[serde(default = "kanidm")] + pub people: String, /// The sender of every mail the site sends (`mail:` in site.yaml). /// Required as soon as any need has `mails:`. #[serde(default)] @@ -364,6 +371,16 @@ fn text() -> String { "text".to_string() } +fn kanidm() -> String { + "kanidm".to_string() +} + +impl Plan { + fn memberships(&self) -> bool { + self.people == "memberships" + } +} + const FIELD_TYPES: &[&str] = &["text", "email", "tel", "textarea", "date", "select", "location"]; fn is_id(s: &str) -> bool { @@ -372,6 +389,9 @@ fn is_id(s: &str) -> bool { impl Plan { pub fn validate(&self) -> anyhow::Result<()> { + if !["kanidm", "memberships"].contains(&self.people.as_str()) { + bail!("people must be `kanidm` or `memberships`, not {:?}", self.people); + } // A contact that is a group has to be one with a desk, or // nobody could sign in as it. if let Some(group) = &self.contact.group { @@ -552,7 +572,11 @@ impl Plan { bail!(at(&format!("grants {state:?} must name a group, not {group:?}"))); } let required = |name: &str, kind: &str| need.collects.iter().any(|f| f.name == name && f.kind == kind && !f.optional); - if !required("name", "text") || !required("email", "email") { + if self.memberships() { + if !required("name", "text") || !(required("email", "email") || required("phone", "tel")) { + bail!(at("a stage that grants a group makes the person a member, so the form must collect a required `name` (text) and a required `phone` (type tel) or `email` (type email)")); + } + } else if !required("name", "text") || !required("email", "email") { bail!(at("a stage that grants a group makes an account for the person, so the form must collect a required `name` (text) and a required `email` (type email)")); } } @@ -1079,13 +1103,27 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap { "description": format!("They get an account, a place in {}, and a one-time link to set their own password or passkey.", label_of(plan, g)), "button": "Invite", "form_title": "Who", - "form_description": loc(lang, "Their name, and the address the account and its one-time link go to.", "Navnet deres, og adressen kontoen og engangslenken går til."), - "labels": { "name": "Name", "email": "Email" }, + "form_description": if plan.memberships() { + loc(lang, "Their name and phone number; an address too, if they have one. Their place is there when they sign in.", "Navnet og telefonnummeret deres, og e-post om de har. Plassen er der når de logger inn.") + } else { + loc(lang, "Their name, and the address the account and its one-time link go to.", "Navnet deres, og adressen kontoen og engangslenken går til.") + }, + "labels": { "name": "Name", "phone": loc(lang, "Phone", "Telefon"), "email": "Email" }, })) }) .collect(); let invited = if invites.is_empty() { Value::Null + } else if plan.memberships() { + json!({ + "heading": loc(lang, "Who is in", "Hvem som er med"), + "description": loc(lang, "Everyone invited into a group this desk may fill, and whether they have come.", "Alle som er invitert inn i en gruppe denne pulten kan fylle, og om de har kommet."), + "done": loc(lang, "Take out", "Ta ut"), + "withdraw": loc(lang, "Withdraw", "Trekk tilbake"), + "again": loc(lang, "Invite again", "Inviter igjen"), + "list_title": loc(lang, "Members and invitations", "Medlemmer og invitasjoner"), + "list_note": loc(lang, "Each row is one person in one group: invited until they sign in, then in. Taking someone out keeps the row, so they can be invited again.", "Hver rad er én person i én gruppe: invitert til de logger inn, så med. Å ta noen ut beholder raden, så de kan inviteres igjen."), + }) } else { json!({ "heading": "Invitations sent", @@ -1979,26 +2017,45 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result = invite["features"][0]["requirements"].as_array().unwrap().iter().map(|r| (r["name"].as_str().unwrap(), r["type"].as_str().unwrap_or("text"))).collect(); + assert_eq!(fields, [("name", "text"), ("phone", "tel"), ("email", "email")]); + assert_eq!(invite["features"][0]["requirements"][2]["optional"], true); + let list = alts.iter().find(|a| a["features"][0]["resource"]["source"]["bucket"] == "portal_memberships").unwrap(); + let moves: Vec<(&str, &str)> = list["features"][0]["resource"]["transitions"].as_array().unwrap().iter().map(|t| (t["from"].as_str().unwrap(), t["to"].as_str().unwrap())).collect(); + assert_eq!(moves, [("invited", "removed"), ("active", "removed"), ("removed", "invited")]); + assert!(!files["questions/board/index.yaml"].contains("portal_invites")); + // A grant may know the person by phone number instead of address. + let by_phone: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [board]\n", "invites_by: [board]\npeople: memberships\nmail: { from: v@v.example }\n").replace(" stages: [open, betalt, avvist]\n", " grants: { betalt: traffic }\n stages: [open, betalt, avvist]\n").replace(" - { name: amount }\n", " - { name: amount }\n - { name: name }\n - { name: phone, type: tel }\n")).unwrap(); + by_phone.validate().unwrap(); + let nobody: Plan = serde_yaml::from_str(&PLAN.replace("business: Vel", "business: Vel\npeople: ldap")).unwrap(); + assert!(nobody.validate().unwrap_err().to_string().contains("people must be")); + } + #[test] fn a_case_left_too_long_goes_up_to_another_desk_with_the_same_buttons() { let plan: Plan = serde_yaml::from_str(&PLAN.replace(" handled_by: traffic\n stages: [open, fixed]\n", " handled_by: traffic\n escalate: { open: { days: 7, to: board } }\n stages: [open, fixed]\n")).unwrap(); diff --git a/src/plan.rs b/src/plan.rs index e96c069..b63d86a 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -169,6 +169,12 @@ rest. Each is one line in the plan, and the builder does the wiring: behind sign-in, with name and address filled from the account: a seat on a committee for members, a discount for customers. The group is one a decision `grants`, so nobody has to be waved in. +- **Keep the people in portal.** `people: memberships` keeps every + group in portal itself: a desk invite is a place held until the + person signs in, a person is their phone number first, and any + sign-in provider that says who someone is will do (Vipps, for a + Norwegian association). Invite forms then ask for a phone number. + Without it, Kanidm makes an account per invite. - **Answer for the pages as a group.** `contact: { name, email, group }` makes the contact a group's inbox rather than a person: no account is made for it, and every member of the group may suggest changes