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::Instant;
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(Error::invalid("email", "is invited already"));
232    }
233    let addressed = AccountEmail::query()
234        .where_(Account::EMAIL.eq(inviting.email.clone()))
235        .first(&unscoped.0.read())
236        .await?;
237    if let Some(addressed) = addressed
238        && membership(&read, organization, addressed.id)
239            .await?
240            .is_some()
241    {
242        return Err(Error::invalid("email", "is a member already"));
243    }
244    #[cfg(feature = "billing")]
245    let kept = crate::organization::seats_kept(&read, &billing, organization, now, 1).await?;
246    #[cfg(not(feature = "billing"))]
247    let kept = Kept::AnOwner;
248    let name = stored(&read, organization).await?;
249    let inviter = AccountEmail::query()
250        .where_(Account::ID.eq(acting.member().account()))
251        .first(&unscoped.0.read())
252        .await?
253        .ok_or(Error::NotFound)?;
254    let invitation = Invitation::new(
255        organization,
256        inviting.email.clone(),
257        inviting.role,
258        inviter.id,
259        now,
260    )?;
261    let link = urls.sign(&invitation_path::<M>(invitation.id()), VALID_FOR)?;
262    let body = mailer.body_from(
263        &INVITATION_MAIL,
264        &json!({
265            "inviter": inviter.email.as_str(),
266            "organization": name.name(),
267            "link": mailer.link(&link)?,
268        }),
269    )?;
270    let subject = format!("Join {}", name.name());
271    let mail = Mail::new(Address::new(&inviting.email)?, &subject, body)?;
272    let once = Invited::of(&invitation, now);
273    Ok(Held::new(organization, kept, (invitation, mailer.send(mail)?)).inviting(once))
274}
275
276async fn withdraw(acting: Acting, mut invitation: Invitation, clock: Clock) -> Result<Invitation> {
277    acting
278        .role()
279        .managing(invitation.role() == OrganizationRole::Owner)?;
280    invitation.withdraw(clock.now())?;
281    Ok(invitation)
282}
283
284async fn not_the_last_owner(read: &Read, member: &OrganizationMember) -> Result<()> {
285    if member.role() != OrganizationRole::Owner {
286        return Ok(());
287    }
288    let owners = OrganizationMember::query()
289        .where_(OrganizationMember::ORGANIZATION.eq(member.organization()))
290        .where_(OrganizationMember::ROLE.eq(OrganizationRole::Owner))
291        .count(read)
292        .await?;
293    if owners <= 1 {
294        return Err(Error::Rule(
295            "An organization keeps at least one owner; make another member owner first.".to_owned(),
296        ));
297    }
298    Ok(())
299}
300
301/// The member's open invitations its new role could not have sent are withdrawn, as on removal.
302async fn assign(
303    acting: Acting,
304    mut member: OrganizationMember,
305    read: Read,
306    clock: Clock,
307    role: OrganizationRole,
308) -> Result<Held<(OrganizationMember, Vec<Invitation>)>> {
309    acting
310        .role()
311        .managing(role == OrganizationRole::Owner || member.role() == OrganizationRole::Owner)?;
312    if role != OrganizationRole::Owner {
313        not_the_last_owner(&read, &member).await?;
314    }
315    member.assign(role)?;
316    let now = clock.now();
317    let withdrawn = beyond_role(&read, &member, now).await?;
318    let demoted = Demoted {
319        account: member.account(),
320        role,
321        now,
322    };
323    Ok(Held::new(member.organization(), Kept::AnOwner, (member, withdrawn)).demoting(demoted))
324}
325
326/// The member's open invitations its role, given just now, could not have sent, withdrawn.
327async fn beyond_role(
328    read: &Read,
329    member: &OrganizationMember,
330    now: Instant,
331) -> Result<Vec<Invitation>> {
332    let mut withdrawn = sent_by(read, member, now).await?;
333    withdrawn.retain(|invitation| {
334        member
335            .role()
336            .managing(invitation.role() == OrganizationRole::Owner)
337            .is_err()
338    });
339    for invitation in &mut withdrawn {
340        invitation.withdraw(now)?;
341    }
342    Ok(withdrawn)
343}
344
345async fn sent_by(
346    read: &Read,
347    member: &OrganizationMember,
348    now: Instant,
349) -> Result<Vec<Invitation>> {
350    Invitation::query()
351        .scope(|query| invitation::pending(query, member.organization(), now))
352        .where_(Invitation::INVITED_BY.eq(member.account()))
353        .all(read)
354        .await
355}
356
357async fn remove<O: OrganizationArea>(
358    acting: Acting,
359    member: OrganizationMember,
360    read: Read,
361    unscoped: Unscoped,
362    clock: Clock,
363) -> Result<()> {
364    if member.id() != acting.member().id() {
365        acting
366            .role()
367            .managing(member.role() == OrganizationRole::Owner)?;
368    }
369    removing::<O>(member, &read, &unscoped, clock.now()).await
370}
371
372async fn leave<O: OrganizationArea>(
373    acting: Acting,
374    read: Read,
375    unscoped: Unscoped,
376    clock: Clock,
377) -> Result<()> {
378    removing::<O>(acting.into_member(), &read, &unscoped, clock.now()).await
379}
380
381/// Its sessions acting for the organization end with it; those acting for its other
382/// organizations stay. Its open invitations there are withdrawn, so none it no longer stands
383/// behind joins anybody or holds a seat.
384async fn removing<O: OrganizationArea>(
385    mut member: OrganizationMember,
386    read: &Read,
387    unscoped: &Unscoped,
388    now: Instant,
389) -> Result<()> {
390    not_the_last_owner(read, &member).await?;
391    member.leave()?;
392    let organization = member.organization();
393    let ended = ending_for::<O>(unscoped, organization, &[member.account()], now).await?;
394    let mut withdrawn = sent_by(read, &member, now).await?;
395    for invitation in &mut withdrawn {
396        invitation.withdraw(now)?;
397    }
398    unscoped
399        .0
400        .commit(&mut Held::new(
401            organization,
402            Kept::AnOwner,
403            (member, ended, withdrawn),
404        ))
405        .await
406}
407
408async fn ending_for<O: OrganizationArea>(
409    unscoped: &Unscoped,
410    organization: OrganizationId,
411    accounts: &[AccountId],
412    now: Instant,
413) -> Result<sofie_identity::sessions::Ended> {
414    sofie_identity::sessions::ending_as(
415        &unscoped.0.read(),
416        accounts,
417        O::NAME,
418        organization.uuid(),
419        now,
420    )
421    .await
422}
423
424/// The owner handing over stays as an admin, and its open invitations of owners are withdrawn.
425async fn hand_over(
426    acting: Acting,
427    mut member: OrganizationMember,
428    read: Read,
429    clock: Clock,
430) -> Result<Held<(OrganizationMember, OrganizationMember, Vec<Invitation>)>> {
431    acting.owning()?;
432    if member.id() == acting.member().id() {
433        return Err(Error::Rule(HAND_OVER_TO_ANOTHER.to_owned()));
434    }
435    member.assign(OrganizationRole::Owner)?;
436    let mut owner = acting.into_member();
437    owner.assign(OrganizationRole::Admin)?;
438    let now = clock.now();
439    let withdrawn = beyond_role(&read, &owner, now).await?;
440    let demoted = Demoted {
441        account: owner.account(),
442        role: OrganizationRole::Admin,
443        now,
444    };
445    Ok(Held::new(
446        member.organization(),
447        Kept::AnOwner,
448        (member, owner, withdrawn),
449    )
450    .demoting(demoted))
451}
452
453const HAND_OVER_TO_ANOTHER: &str = "Hand the organization over to another of its members.";
454
455#[derive(Input)]
456pub struct Renaming {
457    #[max(100)]
458    name: String,
459}
460
461async fn rename<O: OrganizationArea>(
462    acting: Acting,
463    organization: Principal<O>,
464    renaming: Renaming,
465    read: Read,
466) -> Result<Organization> {
467    acting.owning()?;
468    let mut organization = stored(&read, organization.id()).await?;
469    organization.rename(&renaming.name)?;
470    Ok(organization)
471}
472
473/// Every member leaves, its sessions acting for it end, and every open invitation is
474/// withdrawn. What it owns stays for its modules to erase on `OrganizationDeleted`.
475async fn delete<O: OrganizationArea>(
476    acting: Acting,
477    organization: Principal<O>,
478    read: Read,
479    unscoped: Unscoped,
480    clock: Clock,
481    #[cfg(feature = "billing")] billing: sofie::billing::Billing,
482) -> Result<()> {
483    acting.owning()?;
484    let id = organization.id();
485    #[cfg(feature = "billing")]
486    if billing
487        .entitlements(sofie::billing::Subscriber::from(id))
488        .await?
489        .standing()
490        .is_some_and(sofie::billing::Standing::renews_at_provider)
491    {
492        return Err(Error::Rule(PLAN_RUNNING.to_owned()));
493    }
494    let mut organization = stored(&read, id).await?;
495    organization.dissolve()?;
496    let now = clock.now();
497    let mut members = OrganizationMember::query()
498        .where_(OrganizationMember::ORGANIZATION.eq(id))
499        .all(&read)
500        .await?;
501    for member in &mut members {
502        member.leave()?;
503    }
504    let mut withdrawn = Invitation::query()
505        .scope(|query| invitation::pending(query, id, now))
506        .all(&read)
507        .await?;
508    for invitation in &mut withdrawn {
509        invitation.withdraw(now)?;
510    }
511    let accounts: Vec<AccountId> = members.iter().map(OrganizationMember::account).collect();
512    let ended = ending_for::<O>(&unscoped, id, &accounts, now).await?;
513    unscoped
514        .0
515        .commit(&mut Held::new(
516            id,
517            Kept::Nothing,
518            (organization, members, withdrawn, ended),
519        ))
520        .await
521}
522
523#[cfg(feature = "billing")]
524const PLAN_RUNNING: &str = "Cancel the organization's plan before deleting it.";
525
526async fn stored(read: &Read, id: OrganizationId) -> Result<Organization> {
527    Organization::query()
528        .where_(Organization::ID.eq(id))
529        .first(read)
530        .await?
531        .ok_or(Error::NotFound)
532}