build: people: memberships - invite by phone number, list who is in

With portal keeping the groups itself (portal v0.5.7), a desk's invite
form asks for a phone number first, the list of invites is the list
of memberships, and a grant may know the person by number.

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 13:37:02 +02:00
co-authored by Claude Fable 5.1
parent 1025dd7718
commit b0787f7c5a
2 changed files with 98 additions and 13 deletions
+92 -13
View File
@@ -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<String, Value> {
"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<String, Value>) -> anyhow::Result<BT
"action": format!("/{group}"),
"consequence": [s(&iw["button"])],
"invite": { "group": target },
"features": [{ "name": s(&iw["form_title"]), "description": s(&iw["form_description"]), "requirements": [
{ "name": "name", "label": s(&iw["labels"]["name"]) },
{ "name": "email", "label": s(&iw["labels"]["email"]), "type": "email" },
]}],
"features": [{ "name": s(&iw["form_title"]), "description": s(&iw["form_description"]), "requirements": if plan.memberships() {
json!([
{ "name": "name", "label": s(&iw["labels"]["name"]) },
{ "name": "phone", "label": s(&iw["labels"]["phone"]), "type": "tel" },
{ "name": "email", "label": s(&iw["labels"]["email"]), "type": "email", "optional": true },
])
} else {
json!([
{ "name": "name", "label": s(&iw["labels"]["name"]) },
{ "name": "email", "label": s(&iw["labels"]["email"]), "type": "email" },
])
}}],
}));
}
let lw = &w["invited"];
// Through Kanidm an invite is its own case with a link that
// runs out; with memberships the membership is the case.
let (bucket, transitions) = if plan.memberships() {
("portal_memberships", json!([
{ "from": "invited", "to": "removed", "label": s(&lw["withdraw"]) },
{ "from": "active", "to": "removed", "label": s(&lw["done"]) },
{ "from": "removed", "to": "invited", "label": s(&lw["again"]) },
]))
} else {
("portal_invites", json!([
{ "from": "open", "to": "done", "label": s(&lw["done"]) },
{ "from": "expired", "to": "open", "label": s(&lw["again"]) },
{ "from": "expired", "to": "done", "label": s(&lw["done"]) },
]))
};
alternatives.push(json!({
"name": s(&lw["heading"]),
"description": s(&lw["description"]),
"action": format!("/{group}"),
"consequence": [s(&w["confirm"])],
"features": [{ "name": s(&lw["list_title"]), "description": s(&lw["list_note"]), "resource": {
"source": { "kind": "kv", "bucket": "portal_invites" },
"source": { "kind": "kv", "bucket": bucket },
"requires_group": group,
"transitions": [
{ "from": "open", "to": "done", "label": s(&lw["done"]) },
{ "from": "expired", "to": "open", "label": s(&lw["again"]) },
{ "from": "expired", "to": "done", "label": s(&lw["done"]) },
],
"transitions": transitions,
}}],
}));
}
@@ -3062,6 +3119,28 @@ mod invite_tests {
assert!(nobody.validate().unwrap_err().to_string().contains("no desk"));
}
#[test]
fn with_memberships_a_desk_invites_by_phone_and_lists_who_is_in() {
let plan: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [board]\n", "invites_by: [board]\npeople: memberships\n")).unwrap();
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
let board: Value = serde_yaml::from_str(&files["questions/board/index.yaml"]).unwrap();
let alts = board["alternatives"].as_array().unwrap();
let invite = alts.iter().find(|a| a["invite"]["group"] == "traffic").unwrap();
let fields: Vec<(&str, &str)> = 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();
+6
View File
@@ -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