Skip to main content

sofie_tenancy/
organizations.rs

1//! In a member area: an account's organizations, founding one, entering one, and
2//! accepting an invitation. Organizations' records are beyond the account's reach, so
3//! what these change is committed by them, unscoped, after their own checks.
4
5use std::collections::HashMap;
6use std::time::Duration;
7
8use sofie::core::RuleText;
9use sofie::db::{Uuid, in_select};
10use sofie::http::{Admitter, ListedRoute, Query, Routes, Shipped, SignInRight, SignedUrls};
11use sofie::prelude::*;
12
13use sofie_identity::{Account, AccountId, MemberArea, SignedIn, Unscoped, sign_in_for};
14
15use crate::invitation::{Invitation, NOT_OPEN};
16use crate::organization::{
17    Organization, OrganizationArea, OrganizationId, OrganizationMember, OrganizationRole,
18    membership,
19};
20
21/// Entering an organization signs its member in to the organization's area.
22struct Tenancy;
23
24impl Admitter for Tenancy {}
25
26const RIGHT: SignInRight = SignInRight::of::<Tenancy>();
27
28const NOT_A_MEMBER: RuleText = RuleText::new(
29    "tenancy.rule.not_a_member",
30    "You are not a member of this organization.",
31);
32const ANOTHER_ADDRESS: RuleText = RuleText::new(
33    "tenancy.rule.another_address",
34    "This invitation is for another address; sign in with that one.",
35);
36
37const ORGANIZATIONS: &str = r#"<Section id="organizations" title="Organizations">
38  {#each data.organizations as organization}
39    <form method="post" action="organizations/enter"><input type="hidden" name="organization" value={organization.id}><button type="submit">{organization.name} ({organization.role})</button></form>
40  {/each}
41  <Form id="found-organization" post="organizations" target="organizations">
42    <Submit label="Found an organization" />
43  </Form>
44</Section>"#;
45
46const INVITATION: &str = r#"<Section title="Invitation">
47  {#if data.invitation}
48    <p>{data.invitation.organization} invites {data.invitation.email} to join as {data.invitation.role}.</p>
49    <form method="post" action={data.invitation.action}><button type="submit">Join</button></form>
50  {:else}
51    <p>This invitation was accepted already or has expired.</p>
52  {/if}
53</Section>"#;
54
55#[rustfmt::skip]
56pub const ROUTES: &[ListedRoute] = &[
57    ListedRoute::new("member_routes", "GET", "/organizations", "list_organizations"),
58    ListedRoute::new("member_routes", "POST", "/organizations", "found_organization"),
59    ListedRoute::new("member_routes", "POST", "/organizations/enter", "enter_organization"),
60    ListedRoute::new("member_routes", "GET", "/invitations", "invitation_page"),
61    ListedRoute::new("member_routes", "POST", "/invitations", "accept_invitation"),
62];
63
64pub fn routes<M: MemberArea, O: OrganizationArea>(routes: &mut Routes<M>) {
65    let describe: sofie::http::Describe =
66        |screens, key| screens.describe_form::<Founding>(key, "found-organization");
67    routes
68        .shipped(
69            "/organizations",
70            "organizations",
71            (ORGANIZATIONS, Some(describe)),
72            list::<M>,
73        )
74        .name("list_organizations");
75    routes
76        .post("/organizations", found::<M>)
77        .name("found_organization")
78        .then_screen("organizations", "organizations");
79    routes
80        .post("/organizations/enter", enter::<M, O>)
81        .name("enter_organization")
82        .then_redirect(O::HOME);
83    routes
84        .shipped(
85            "/invitations",
86            "invitation",
87            (INVITATION, None),
88            invitation_page::<M>,
89        )
90        .name("invitation_page")
91        .signed();
92    routes
93        .post("/invitations", accept::<M>)
94        .name("accept_invitation")
95        .signed()
96        .then_redirect(&format!("{}/organizations", M::PREFIX));
97}
98
99pub(crate) fn invitation_path<M: MemberArea>(invitation: Id<Invitation>) -> String {
100    format!("{}/invitations?invitation={}", M::PREFIX, invitation.uuid())
101}
102
103#[derive(ReadModel)]
104#[read(from = Account)]
105pub(crate) struct AccountEmail {
106    pub(crate) id: AccountId,
107    pub(crate) email: Email,
108}
109
110#[derive(ReadModel)]
111#[read(from = OrganizationMember)]
112struct Held {
113    organization: OrganizationId,
114    role: OrganizationRole,
115}
116
117#[derive(ReadModel)]
118#[read(from = Organization)]
119struct Named {
120    id: OrganizationId,
121    name: String,
122}
123
124#[derive(ReadModel)]
125struct Listed {
126    id: OrganizationId,
127    name: String,
128    role: OrganizationRole,
129}
130
131#[derive(ReadModel)]
132struct Organizations {
133    organizations: Vec<Listed>,
134}
135
136async fn list<M: MemberArea>(principal: Principal<M>, unscoped: Unscoped) -> Result<Organizations> {
137    let read = unscoped.0.read();
138    let account = principal.id();
139    let held = Held::query()
140        .where_(OrganizationMember::ACCOUNT.eq(account))
141        .all(&read)
142        .await?;
143    let mut names: HashMap<OrganizationId, String> = Named::query()
144        .where_(in_select(
145            Organization::ID,
146            OrganizationMember::ORGANIZATION,
147            OrganizationMember::ACCOUNT.eq(account),
148        ))
149        .all(&read)
150        .await?
151        .into_iter()
152        .map(|named| (named.id, named.name))
153        .collect();
154    let organizations = held
155        .into_iter()
156        .filter_map(|held| {
157            Some(Listed {
158                id: held.organization,
159                name: names.remove(&held.organization)?,
160                role: held.role,
161            })
162        })
163        .collect();
164    Ok(Organizations { organizations })
165}
166
167#[derive(Input)]
168pub struct Founding {
169    #[max(100)]
170    name: String,
171}
172
173async fn found<M: MemberArea>(
174    principal: Principal<M>,
175    founding: Founding,
176    unscoped: Unscoped,
177) -> Result<()> {
178    let organization = Organization::named(&founding.name)?;
179    let owner =
180        OrganizationMember::new(organization.id(), principal.id(), OrganizationRole::Owner)?;
181    unscoped.0.commit(&mut (organization, owner)).await
182}
183
184#[derive(Input)]
185pub struct Entering {
186    organization: OrganizationId,
187}
188
189async fn enter<M: MemberArea, O: OrganizationArea>(
190    principal: Principal<M>,
191    entering: Entering,
192    unscoped: Unscoped,
193    session: Session,
194    clock: Clock,
195) -> Result<SignedIn> {
196    let account = principal.id();
197    if membership(&unscoped.0.read(), entering.organization, account)
198        .await?
199        .is_none()
200    {
201        return Err(NOT_A_MEMBER.into());
202    }
203    sign_in_for::<O>(&session, RIGHT, entering.organization, account, &clock)
204}
205
206fn invitation_id(query: &Query) -> Result<Id<Invitation>> {
207    query
208        .get("invitation")
209        .and_then(|id| id.parse::<Uuid>().ok())
210        .map(Id::from_uuid)
211        .ok_or(Error::NotFound)
212}
213
214async fn invited(read: &Read, id: Id<Invitation>) -> Result<Invitation> {
215    Invitation::query()
216        .where_(Invitation::ID.eq(id))
217        .first(read)
218        .await?
219        .ok_or(Error::NotFound)
220}
221
222#[derive(ReadModel)]
223struct Open {
224    organization: String,
225    email: Email,
226    role: OrganizationRole,
227    action: String,
228}
229
230#[derive(ReadModel)]
231struct InvitationPage {
232    invitation: Option<Open>,
233}
234
235async fn invitation_page<M: MemberArea>(
236    _principal: Principal<M>,
237    query: Query,
238    unscoped: Unscoped,
239    urls: SignedUrls,
240    clock: Clock,
241) -> Result<InvitationPage> {
242    let read = unscoped.0.read();
243    let id = invitation_id(&query)?;
244    let invitation = invited(&read, id).await?;
245    let now = clock.now();
246    if !invitation.is_open(now) {
247        return Ok(InvitationPage { invitation: None });
248    }
249    let organization = Named::query()
250        .where_(Organization::ID.eq(invitation.organization()))
251        .first(&read)
252        .await?
253        .ok_or(Error::NotFound)?;
254    let left = invitation
255        .expires_at()
256        .timestamp()
257        .duration_since(now.timestamp());
258    let left = Duration::try_from(left).map_err(|_| Error::from(NOT_OPEN))?;
259    Ok(InvitationPage {
260        invitation: Some(Open {
261            organization: organization.name,
262            email: invitation.email().clone(),
263            role: invitation.role(),
264            action: urls.sign(&invitation_path::<M>(id), left)?,
265        }),
266    })
267}
268
269/// Only for the account the invitation's address belongs to, so a forwarded mail adds
270/// nobody else; the mailed link it was opened by confirms that address.
271/// An account already a member keeps the higher of its role and the invitation's.
272async fn accept<M: MemberArea>(
273    principal: Principal<M>,
274    query: Query,
275    unscoped: Unscoped,
276    clock: Clock,
277    #[cfg(feature = "billing")] billing: sofie::billing::Billing,
278) -> Result<()> {
279    let read = unscoped.0.read();
280    let mut account = Account::query()
281        .where_(Account::ID.eq(principal.id()))
282        .first(&read)
283        .await?
284        .ok_or(Error::NotFound)?;
285    let mut invitation = invited(&read, invitation_id(&query)?).await?;
286    if account.email() != invitation.email() {
287        return Err(ANOTHER_ADDRESS.into());
288    }
289    let now = clock.now();
290    let confirmed = account.verified_at().is_none().then(|| {
291        account.confirm_email(now);
292        account
293    });
294    let organization = invitation.organization();
295    #[cfg(feature = "billing")]
296    let kept = crate::organization::seats_kept(&read, &billing, organization, now, 0).await?;
297    #[cfg(not(feature = "billing"))]
298    let kept = crate::organization::Kept::AnOwner;
299    invitation.accept(now)?;
300    let joined = match membership(&read, organization, principal.id()).await? {
301        Some(mut member) if invitation.role().outranks(member.role()) => {
302            member.assign(invitation.role())?;
303            vec![member]
304        }
305        Some(_) => Vec::new(),
306        None => vec![OrganizationMember::new(
307            organization,
308            principal.id(),
309            invitation.role(),
310        )?],
311    };
312    unscoped
313        .0
314        .commit(&mut crate::organization::Held::new(
315            organization,
316            kept,
317            (invitation, joined, confirmed),
318        ))
319        .await
320}