1use 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
21struct 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
269async 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}