Skip to main content

sofie_tenancy/
members.rs

1//! In an organization area: its members and pending invitations, inviting, roles, removal and
2//! leaving, and the owners' renaming, handing over and deleting the organization.
3
4use std::collections::HashMap;
5
6use sofie::core::{FieldError, Instant, RuleText};
7use sofie::db::{Queued, in_select};
8use sofie::http::{ListedRoute, Rate, Routes, Shipped, SignedUrls};
9use sofie::mail::{Address, Mail, Mailer, Template};
10use sofie::prelude::*;
11use sofie::serde_json::json;
12
13use sofie_identity::{Account, AccountId, MemberArea, Unscoped};
14
15use crate::acting::Acting;
16use crate::invitation::{self, Invitation, VALID_FOR};
17use crate::organization::{
18    Demoted, Held, Invited, Kept, Organization, OrganizationArea, OrganizationId,
19    OrganizationMember, OrganizationRole, membership,
20};
21use crate::organizations::{AccountEmail, invitation_path};
22
23const INVITATION_MAIL: Template = Template::new(
24    "sofie_tenancy/invitation",
25    sofie::db::embed!(
26        "src/mails/invitation.html",
27        compiled = "mails/sofie_tenancy/invitation.sft"
28    ),
29);
30
31const MEMBERS: &str = r#"<Section id="members" title="Members">
32  <Table id="member-list" of={data.members} columns="email role" as="member">
33    <Action post="members/{member.id}/admin" label="Make admin" target="members" />
34    <Action post="members/{member.id}/member" label="Make member" target="members" />
35    <Action post="members/{member.id}/remove" label="Remove" target="members" />
36    <Action post="members/{member.id}/handover" label="Hand over ownership" target="members" />
37  </Table>
38  <Table id="invitations" of={data.invitations} columns="email role expires_at" as="invitation">
39    <Action post="members/invitations/{invitation.id}/withdraw" label="Withdraw" target="members" />
40  </Table>
41  <Form id="invite" post="members/invite" target="members">
42    <Submit label="Invite" />
43  </Form>
44  <Form id="rename" post="organization/rename" target="members">
45    <Submit label="Rename the organization" />
46  </Form>
47  <form method="post" action="members/leave"><button type="submit">Leave the organization</button></form>
48  <form method="post" action="organization/delete"><button type="submit">Delete the organization</button></form>
49</Section>"#;
50
51/// Per caller: each invitation mails an address the caller names.
52const INVITING: Rate = Rate::per_minute(10);
53
54#[rustfmt::skip]
55pub const ROUTES: &[ListedRoute] = &[
56    ListedRoute::new("organization_routes", "GET", "/members", "list_members"),
57    ListedRoute::new("organization_routes", "POST", "/members/invite", "invite_member"),
58    ListedRoute::new("organization_routes", "POST", "/members/{organization_member}/owner", "make_owner"),
59    ListedRoute::new("organization_routes", "POST", "/members/{organization_member}/admin", "make_admin"),
60    ListedRoute::new("organization_routes", "POST", "/members/{organization_member}/member", "make_member"),
61    ListedRoute::new("organization_routes", "POST", "/members/invitations/{invitation}/withdraw", "withdraw_invitation"),
62    ListedRoute::new("organization_routes", "POST", "/members/{organization_member}/remove", "remove_member"),
63    ListedRoute::new("organization_routes", "POST", "/members/{organization_member}/handover", "hand_over_organization"),
64    ListedRoute::new("organization_routes", "POST", "/members/leave", "leave_organization"),
65    ListedRoute::new("organization_routes", "POST", "/organization/rename", "rename_organization"),
66    ListedRoute::new("organization_routes", "POST", "/organization/delete", "delete_organization"),
67];
68
69/// `M` is the member area an invitation is accepted in.
70pub fn routes<O: OrganizationArea, M: MemberArea>(routes: &mut Routes<O>) {
71    let describe: sofie::http::Describe = |screens, key| {
72        screens.describe_form::<Inviting>(key, "invite")?;
73        screens.describe_form::<Renaming>(key, "rename")
74    };
75    routes
76        .shipped("/members", "members", (MEMBERS, Some(describe)), list::<O>)
77        .name("list_members");
78    routes
79        .post("/members/invite", invite::<O, M>)
80        .name("invite_member")
81        .throttle(INVITING)
82        .then_screen("members", "members");
83    for (role, path, handler) in [
84        (OrganizationRole::Owner, "owner", "make_owner"),
85        (OrganizationRole::Admin, "admin", "make_admin"),
86        (OrganizationRole::Member, "member", "make_member"),
87    ] {
88        let assigning =
89            move |acting: Acting, member: OrganizationMember, read: Read, clock: Clock| {
90                assign(acting, member, read, clock, role)
91            };
92        routes
93            .post(
94                &format!("/members/{{organization_member}}/{path}"),
95                assigning,
96            )
97            .name(handler)
98            .then_screen("members", "members");
99    }
100    routes
101        .post("/members/invitations/{invitation}/withdraw", withdraw)
102        .name("withdraw_invitation")
103        .then_screen("members", "members");
104    routes
105        .post("/members/{organization_member}/remove", remove::<O>)
106        .name("remove_member")
107        .then_screen("members", "members");
108    routes
109        .post("/members/{organization_member}/handover", hand_over)
110        .name("hand_over_organization")
111        .then_screen("members", "members");
112    routes
113        .post("/members/leave", leave::<O>)
114        .name("leave_organization")
115        .then_redirect(M::HOME);
116    routes
117        .post("/organization/rename", rename::<O>)
118        .name("rename_organization")
119        .then_screen("members", "members");
120    routes
121        .post("/organization/delete", delete::<O>)
122        .name("delete_organization")
123        .then_redirect(M::HOME);
124}
125
126#[derive(ReadModel)]
127#[read(from = OrganizationMember)]
128struct Membership {
129    id: Id<OrganizationMember>,
130    account: AccountId,
131    role: OrganizationRole,
132}
133
134#[derive(ReadModel)]
135#[read(from = Invitation)]
136struct Pending {
137    id: Id<Invitation>,
138    email: Email,
139    role: OrganizationRole,
140    expires_at: Instant,
141}
142
143#[derive(ReadModel)]
144struct Listed {
145    id: Id<OrganizationMember>,
146    email: Option<Email>,
147    role: OrganizationRole,
148}
149
150#[derive(ReadModel)]
151struct Members {
152    members: Vec<Listed>,
153    invitations: Vec<Pending>,
154}
155
156/// Addresses are the accounts', read beyond the organization's reach.
157async fn list<O: OrganizationArea>(
158    _acting: Acting,
159    organization: Principal<O>,
160    read: Read,
161    unscoped: Unscoped,
162    clock: Clock,
163) -> Result<Members> {
164    let organization = organization.id();
165    let memberships = Membership::query()
166        .where_(OrganizationMember::ORGANIZATION.eq(organization))
167        .all(&read)
168        .await?;
169    let addresses: HashMap<AccountId, Email> = AccountEmail::query()
170        .where_(in_select(
171            Account::ID,
172            OrganizationMember::ACCOUNT,
173            OrganizationMember::ORGANIZATION.eq(organization),
174        ))
175        .all(&unscoped.0.read())
176        .await?
177        .into_iter()
178        .map(|address| (address.id, address.email))
179        .collect();
180    let members = memberships
181        .into_iter()
182        .map(|member| Listed {
183            id: member.id,
184            email: addresses.get(&member.account).cloned(),
185            role: member.role,
186        })
187        .collect();
188    let invitations = Pending::query()
189        .scope(|query| invitation::pending(query, organization, clock.now()))
190        .all(&read)
191        .await?;
192    Ok(Members {
193        members,
194        invitations,
195    })
196}
197
198#[derive(Input)]
199pub struct Inviting {
200    email: Email,
201    role: OrganizationRole,
202}
203
204#[expect(
205    clippy::too_many_arguments,
206    reason = "each is a separate resolved argument"
207)]
208async fn invite<O: OrganizationArea, M: MemberArea>(
209    acting: Acting,
210    organization: Principal<O>,
211    inviting: Inviting,
212    read: Read,
213    unscoped: Unscoped,
214    mailer: Mailer,
215    urls: SignedUrls,
216    clock: Clock,
217    #[cfg(feature = "billing")] billing: sofie::billing::Billing,
218) -> Result<Held<(Invitation, Queued)>> {
219    acting
220        .role()
221        .managing(inviting.role == OrganizationRole::Owner)?;
222    let organization = organization.id();
223    let now = clock.now();
224    let pending = Invitation::query().scope(|query| invitation::pending(query, organization, now));
225    if pending
226        .clone()
227        .where_(Invitation::EMAIL.eq(inviting.email.clone()))
228        .exists(&read)
229        .await?
230    {
231        return Err(refused_address(
232            "tenancy.invitation.invited_already",
233            "is invited already",
234        ));
235    }
236    let addressed = AccountEmail::query()
237        .where_(Account::EMAIL.eq(inviting.email.clone()))
238        .first(&unscoped.0.read())
239        .await?;
240    if let Some(addressed) = addressed
241        && membership(&read, organization, addressed.id)
242            .await?
243            .is_some()
244    {
245        return Err(refused_address(
246            "tenancy.invitation.member_already",
247            "is a member already",
248        ));
249    }
250    #[cfg(feature = "billing")]
251    let kept = crate::organization::seats_kept(&read, &billing, organization, now, 1).await?;
252    #[cfg(not(feature = "billing"))]
253    let kept = Kept::AnOwner;
254    let name = stored(&read, organization).await?;
255    let inviter = AccountEmail::query()
256        .where_(Account::ID.eq(acting.member().account()))
257        .first(&unscoped.0.read())
258        .await?
259        .ok_or(Error::NotFound)?;
260    let invitation = Invitation::new(
261        organization,
262        inviting.email.clone(),
263        inviting.role,
264        inviter.id,
265        now,
266    )?;
267    let link = urls.sign(&invitation_path::<M>(invitation.id()), VALID_FOR)?;
268    let body = mailer.body_from(
269        &INVITATION_MAIL,
270        &json!({
271            "inviter": inviter.email.as_str(),
272            "organization": name.name(),
273            "link": mailer.link(&link)?,
274        }),
275    )?;
276    let subject = mailer.text(
277        "tenancy.invitation_mail.subject",
278        &[("organization", name.name().to_owned())],
279        &format!("Join {}", name.name()),
280    );
281    let mail = Mail::new(Address::new(&inviting.email)?, &subject, body)?;
282    let once = Invited::of(&invitation, now);
283    Ok(Held::new(organization, kept, (invitation, mailer.send(mail)?)).inviting(once))
284}
285
286fn refused_address(key: &'static str, english: &str) -> Error {
287    Error::Invalid(vec![FieldError::new("email", key, english)])
288}
289
290async fn withdraw(acting: Acting, mut invitation: Invitation, clock: Clock) -> Result<Invitation> {
291    acting
292        .role()
293        .managing(invitation.role() == OrganizationRole::Owner)?;
294    invitation.withdraw(clock.now())?;
295    Ok(invitation)
296}
297
298async fn not_the_last_owner(read: &Read, member: &OrganizationMember) -> Result<()> {
299    if member.role() != OrganizationRole::Owner {
300        return Ok(());
301    }
302    let owners = OrganizationMember::query()
303        .where_(OrganizationMember::ORGANIZATION.eq(member.organization()))
304        .where_(OrganizationMember::ROLE.eq(OrganizationRole::Owner))
305        .count(read)
306        .await?;
307    if owners <= 1 {
308        return Err(LAST_OWNER.into());
309    }
310    Ok(())
311}
312
313/// The member's open invitations its new role could not have sent are withdrawn, as on removal.
314async fn assign(
315    acting: Acting,
316    mut member: OrganizationMember,
317    read: Read,
318    clock: Clock,
319    role: OrganizationRole,
320) -> Result<Held<(OrganizationMember, Vec<Invitation>)>> {
321    acting
322        .role()
323        .managing(role == OrganizationRole::Owner || member.role() == OrganizationRole::Owner)?;
324    if role != OrganizationRole::Owner {
325        not_the_last_owner(&read, &member).await?;
326    }
327    member.assign(role)?;
328    let now = clock.now();
329    let withdrawn = beyond_role(&read, &member, now).await?;
330    let demoted = Demoted {
331        account: member.account(),
332        role,
333        now,
334    };
335    Ok(Held::new(member.organization(), Kept::AnOwner, (member, withdrawn)).demoting(demoted))
336}
337
338/// The member's open invitations its role, given just now, could not have sent, withdrawn.
339async fn beyond_role(
340    read: &Read,
341    member: &OrganizationMember,
342    now: Instant,
343) -> Result<Vec<Invitation>> {
344    let mut withdrawn = sent_by(read, member, now).await?;
345    withdrawn.retain(|invitation| {
346        member
347            .role()
348            .managing(invitation.role() == OrganizationRole::Owner)
349            .is_err()
350    });
351    for invitation in &mut withdrawn {
352        invitation.withdraw(now)?;
353    }
354    Ok(withdrawn)
355}
356
357async fn sent_by(
358    read: &Read,
359    member: &OrganizationMember,
360    now: Instant,
361) -> Result<Vec<Invitation>> {
362    Invitation::query()
363        .scope(|query| invitation::pending(query, member.organization(), now))
364        .where_(Invitation::INVITED_BY.eq(member.account()))
365        .all(read)
366        .await
367}
368
369async fn remove<O: OrganizationArea>(
370    acting: Acting,
371    member: OrganizationMember,
372    read: Read,
373    unscoped: Unscoped,
374    clock: Clock,
375) -> Result<()> {
376    if member.id() != acting.member().id() {
377        acting
378            .role()
379            .managing(member.role() == OrganizationRole::Owner)?;
380    }
381    removing::<O>(member, &read, &unscoped, clock.now()).await
382}
383
384async fn leave<O: OrganizationArea>(
385    acting: Acting,
386    read: Read,
387    unscoped: Unscoped,
388    clock: Clock,
389) -> Result<()> {
390    removing::<O>(acting.into_member(), &read, &unscoped, clock.now()).await
391}
392
393/// Its sessions acting for the organization end with it; those acting for its other
394/// organizations stay. Its open invitations there are withdrawn, so none it no longer stands
395/// behind joins anybody or holds a seat.
396async fn removing<O: OrganizationArea>(
397    mut member: OrganizationMember,
398    read: &Read,
399    unscoped: &Unscoped,
400    now: Instant,
401) -> Result<()> {
402    not_the_last_owner(read, &member).await?;
403    member.leave()?;
404    let organization = member.organization();
405    let ended = ending_for::<O>(unscoped, organization, &[member.account()], now).await?;
406    let mut withdrawn = sent_by(read, &member, now).await?;
407    for invitation in &mut withdrawn {
408        invitation.withdraw(now)?;
409    }
410    unscoped
411        .0
412        .commit(&mut Held::new(
413            organization,
414            Kept::AnOwner,
415            (member, ended, withdrawn),
416        ))
417        .await
418}
419
420async fn ending_for<O: OrganizationArea>(
421    unscoped: &Unscoped,
422    organization: OrganizationId,
423    accounts: &[AccountId],
424    now: Instant,
425) -> Result<sofie_identity::sessions::Ended> {
426    sofie_identity::sessions::ending_as(
427        &unscoped.0.read(),
428        accounts,
429        O::NAME,
430        organization.uuid(),
431        now,
432    )
433    .await
434}
435
436/// The owner handing over stays as an admin, and its open invitations of owners are withdrawn.
437async fn hand_over(
438    acting: Acting,
439    mut member: OrganizationMember,
440    read: Read,
441    clock: Clock,
442) -> Result<Held<(OrganizationMember, OrganizationMember, Vec<Invitation>)>> {
443    acting.owning()?;
444    if member.id() == acting.member().id() {
445        return Err(HAND_OVER_TO_ANOTHER.into());
446    }
447    member.assign(OrganizationRole::Owner)?;
448    let mut owner = acting.into_member();
449    owner.assign(OrganizationRole::Admin)?;
450    let now = clock.now();
451    let withdrawn = beyond_role(&read, &owner, now).await?;
452    let demoted = Demoted {
453        account: owner.account(),
454        role: OrganizationRole::Admin,
455        now,
456    };
457    Ok(Held::new(
458        member.organization(),
459        Kept::AnOwner,
460        (member, owner, withdrawn),
461    )
462    .demoting(demoted))
463}
464
465const LAST_OWNER: RuleText = RuleText::new(
466    "tenancy.rule.last_owner",
467    "An organization keeps at least one owner; make another member owner first.",
468);
469const HAND_OVER_TO_ANOTHER: RuleText = RuleText::new(
470    "tenancy.rule.hand_over_to_another",
471    "Hand the organization over to another of its members.",
472);
473
474#[derive(Input)]
475pub struct Renaming {
476    #[max(100)]
477    name: String,
478}
479
480async fn rename<O: OrganizationArea>(
481    acting: Acting,
482    organization: Principal<O>,
483    renaming: Renaming,
484    read: Read,
485) -> Result<Organization> {
486    acting.owning()?;
487    let mut organization = stored(&read, organization.id()).await?;
488    organization.rename(&renaming.name)?;
489    Ok(organization)
490}
491
492/// Every member leaves, its sessions acting for it end, and every open invitation is
493/// withdrawn. What it owns stays for its modules to erase on `OrganizationDeleted`.
494async fn delete<O: OrganizationArea>(
495    acting: Acting,
496    organization: Principal<O>,
497    read: Read,
498    unscoped: Unscoped,
499    clock: Clock,
500    #[cfg(feature = "billing")] billing: sofie::billing::Billing,
501) -> Result<()> {
502    acting.owning()?;
503    let id = organization.id();
504    #[cfg(feature = "billing")]
505    if billing
506        .entitlements(sofie::billing::Subscriber::from(id))
507        .await?
508        .standing()
509        .is_some_and(sofie::billing::Standing::renews_at_provider)
510    {
511        return Err(PLAN_RUNNING.into());
512    }
513    let mut organization = stored(&read, id).await?;
514    organization.dissolve()?;
515    let now = clock.now();
516    let mut members = OrganizationMember::query()
517        .where_(OrganizationMember::ORGANIZATION.eq(id))
518        .all(&read)
519        .await?;
520    for member in &mut members {
521        member.leave()?;
522    }
523    let mut withdrawn = Invitation::query()
524        .scope(|query| invitation::pending(query, id, now))
525        .all(&read)
526        .await?;
527    for invitation in &mut withdrawn {
528        invitation.withdraw(now)?;
529    }
530    let accounts: Vec<AccountId> = members.iter().map(OrganizationMember::account).collect();
531    let ended = ending_for::<O>(&unscoped, id, &accounts, now).await?;
532    unscoped
533        .0
534        .commit(&mut Held::new(
535            id,
536            Kept::Nothing,
537            (organization, members, withdrawn, ended),
538        ))
539        .await
540}
541
542#[cfg(feature = "billing")]
543const PLAN_RUNNING: RuleText = RuleText::new(
544    "tenancy.rule.plan_running",
545    "Cancel the organization's plan before deleting it.",
546);
547
548async fn stored(read: &Read, id: OrganizationId) -> Result<Organization> {
549    Organization::query()
550        .where_(Organization::ID.eq(id))
551        .first(read)
552        .await?
553        .ok_or(Error::NotFound)
554}