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:
co-authored by
Claude Fable 5.1
parent
1025dd7718
commit
b0787f7c5a
+92
-13
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user