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