1use 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
20struct 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
270async 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}