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::{Uuid, in_select};
9use sofie::http::{Admitter, ListedRoute, Query, Routes, Shipped, SignInRight, SignedUrls};
10use sofie::prelude::*;
11
12use sofie_identity::{Account, AccountId, MemberArea, SignedIn, Unscoped, 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
96#[derive(ReadModel)]
97#[read(from = Account)]
98pub(crate) struct AccountEmail {
99    pub(crate) id: AccountId,
100    pub(crate) email: Email,
101}
102
103#[derive(ReadModel)]
104#[read(from = OrganizationMember)]
105struct Held {
106    organization: OrganizationId,
107    role: OrganizationRole,
108}
109
110#[derive(ReadModel)]
111#[read(from = Organization)]
112struct Named {
113    id: OrganizationId,
114    name: String,
115}
116
117#[derive(ReadModel)]
118struct Listed {
119    id: OrganizationId,
120    name: String,
121    role: OrganizationRole,
122}
123
124#[derive(ReadModel)]
125struct Organizations {
126    organizations: Vec<Listed>,
127}
128
129async fn list<M: MemberArea>(principal: Principal<M>, unscoped: Unscoped) -> Result<Organizations> {
130    let read = unscoped.0.read();
131    let account = principal.id();
132    let held = Held::query()
133        .where_(OrganizationMember::ACCOUNT.eq(account))
134        .all(&read)
135        .await?;
136    let mut names: HashMap<OrganizationId, String> = Named::query()
137        .where_(in_select(
138            Organization::ID,
139            OrganizationMember::ORGANIZATION,
140            OrganizationMember::ACCOUNT.eq(account),
141        ))
142        .all(&read)
143        .await?
144        .into_iter()
145        .map(|named| (named.id, named.name))
146        .collect();
147    let organizations = held
148        .into_iter()
149        .filter_map(|held| {
150            Some(Listed {
151                id: held.organization,
152                name: names.remove(&held.organization)?,
153                role: held.role,
154            })
155        })
156        .collect();
157    Ok(Organizations { organizations })
158}
159
160#[derive(Input)]
161pub struct Founding {
162    #[max(100)]
163    name: String,
164}
165
166async fn found<M: MemberArea>(
167    principal: Principal<M>,
168    founding: Founding,
169    unscoped: Unscoped,
170) -> Result<()> {
171    let organization = Organization::named(&founding.name)?;
172    let owner =
173        OrganizationMember::new(organization.id(), principal.id(), OrganizationRole::Owner)?;
174    unscoped.0.commit(&mut (organization, owner)).await
175}
176
177#[derive(Input)]
178pub struct Entering {
179    organization: OrganizationId,
180}
181
182async fn enter<M: MemberArea, O: OrganizationArea>(
183    principal: Principal<M>,
184    entering: Entering,
185    unscoped: Unscoped,
186    session: Session,
187    clock: Clock,
188) -> Result<SignedIn> {
189    let account = principal.id();
190    if membership(&unscoped.0.read(), entering.organization, account)
191        .await?
192        .is_none()
193    {
194        return Err(Error::Rule(NOT_A_MEMBER.to_owned()));
195    }
196    sign_in_for::<O>(&session, RIGHT, entering.organization, account, &clock)
197}
198
199fn invitation_id(query: &Query) -> Result<Id<Invitation>> {
200    query
201        .get("invitation")
202        .and_then(|id| id.parse::<Uuid>().ok())
203        .map(Id::from_uuid)
204        .ok_or(Error::NotFound)
205}
206
207async fn invited(read: &Read, id: Id<Invitation>) -> Result<Invitation> {
208    Invitation::query()
209        .where_(Invitation::ID.eq(id))
210        .first(read)
211        .await?
212        .ok_or(Error::NotFound)
213}
214
215#[derive(ReadModel)]
216struct Open {
217    organization: String,
218    email: Email,
219    role: OrganizationRole,
220    action: String,
221}
222
223#[derive(ReadModel)]
224struct InvitationPage {
225    invitation: Option<Open>,
226}
227
228async fn invitation_page<M: MemberArea>(
229    _principal: Principal<M>,
230    query: Query,
231    unscoped: Unscoped,
232    urls: SignedUrls,
233    clock: Clock,
234) -> Result<InvitationPage> {
235    let read = unscoped.0.read();
236    let id = invitation_id(&query)?;
237    let invitation = invited(&read, id).await?;
238    let now = clock.now();
239    if !invitation.is_open(now) {
240        return Ok(InvitationPage { invitation: None });
241    }
242    let organization = Named::query()
243        .where_(Organization::ID.eq(invitation.organization()))
244        .first(&read)
245        .await?
246        .ok_or(Error::NotFound)?;
247    let left = invitation
248        .expires_at()
249        .timestamp()
250        .duration_since(now.timestamp());
251    let left = Duration::try_from(left).map_err(|_| Error::Rule(NOT_OPEN.to_owned()))?;
252    Ok(InvitationPage {
253        invitation: Some(Open {
254            organization: organization.name,
255            email: invitation.email().clone(),
256            role: invitation.role(),
257            action: urls.sign(&invitation_path::<M>(id), left)?,
258        }),
259    })
260}
261
262/// Only for the account the invitation's address belongs to, so a forwarded mail adds
263/// nobody else; the mailed link it was opened by confirms that address.
264/// An account already a member keeps the higher of its role and the invitation's.
265async fn accept<M: MemberArea>(
266    principal: Principal<M>,
267    query: Query,
268    unscoped: Unscoped,
269    clock: Clock,
270    #[cfg(feature = "billing")] billing: sofie::billing::Billing,
271) -> Result<()> {
272    let read = unscoped.0.read();
273    let mut account = Account::query()
274        .where_(Account::ID.eq(principal.id()))
275        .first(&read)
276        .await?
277        .ok_or(Error::NotFound)?;
278    let mut invitation = invited(&read, invitation_id(&query)?).await?;
279    if account.email() != invitation.email() {
280        return Err(Error::Rule(ANOTHER_ADDRESS.to_owned()));
281    }
282    let now = clock.now();
283    let confirmed = account.verified_at().is_none().then(|| {
284        account.confirm_email(now);
285        account
286    });
287    let organization = invitation.organization();
288    #[cfg(feature = "billing")]
289    let kept = crate::organization::seats_kept(&read, &billing, organization, now, 0).await?;
290    #[cfg(not(feature = "billing"))]
291    let kept = crate::organization::Kept::AnOwner;
292    invitation.accept(now)?;
293    let joined = match membership(&read, organization, principal.id()).await? {
294        Some(mut member) if invitation.role().outranks(member.role()) => {
295            member.assign(invitation.role())?;
296            vec![member]
297        }
298        Some(_) => Vec::new(),
299        None => vec![OrganizationMember::new(
300            organization,
301            principal.id(),
302            invitation.role(),
303        )?],
304    };
305    unscoped
306        .0
307        .commit(&mut crate::organization::Held::new(
308            organization,
309            kept,
310            (invitation, joined, confirmed),
311        ))
312        .await
313}