1use 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
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
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
262async 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}