1use std::path::PathBuf;
11use std::sync::Arc;
12
13use allowthem_core::error::AuthError;
14use allowthem_core::types::UserId;
15use allowthem_core::{AllowThem, Email, User};
16
17use crate::cache::HandleCache;
18use crate::control_db::ControlDb;
19use crate::error::SaasError;
20use crate::tenants::{ProvisionResult, TenantBuilderConfig};
21
22pub fn dashboard_cookie_name(is_production: bool) -> &'static str {
28 if is_production {
29 "__Host-allowthem_dashboard_session"
30 } else {
31 "allowthem_dashboard_session"
32 }
33}
34
35#[derive(Clone)]
41pub struct DashboardState {
42 pub ath: AllowThem,
44 pub control_db: Arc<ControlDb>,
46 pub tenant_data_dir: PathBuf,
48 pub tenant_config: Arc<TenantBuilderConfig>,
49 pub handle_cache: HandleCache,
52 pub is_production: bool,
55}
56
57pub struct DashboardSignupParams {
59 pub email: String,
60 pub password: String,
61 pub tenant_name: String,
62 pub tenant_slug: String,
63}
64
65pub struct DashboardSignupResult {
67 pub user: User,
69 pub provision: ProvisionResult,
72 pub set_cookie: String,
74}
75
76#[derive(Debug, thiserror::Error)]
78pub enum DashboardSignupError {
79 #[error("invalid email")]
80 InvalidEmail,
81 #[error("email already in use")]
82 EmailTaken,
83 #[error("provision failed: {0}")]
84 ProvisionFailed(SaasError),
85 #[error(transparent)]
86 Auth(AuthError),
87}
88
89pub async fn dashboard_signup(
101 state: &DashboardState,
102 params: DashboardSignupParams,
103) -> Result<DashboardSignupResult, DashboardSignupError> {
104 let email = Email::new(params.email.clone()).map_err(|_| DashboardSignupError::InvalidEmail)?;
106
107 let user = state
108 .ath
109 .db()
110 .create_user(email.clone(), ¶ms.password, None, None)
111 .await
112 .map_err(|e| match e {
113 AuthError::Conflict(ref msg) if msg.contains("email") => {
114 DashboardSignupError::EmailTaken
115 }
116 other => DashboardSignupError::Auth(other),
117 })?;
118
119 let provision = match state
121 .control_db
122 .provision_tenant(
123 params.tenant_name,
124 params.tenant_slug,
125 email.as_str().to_owned(),
126 &state.tenant_data_dir,
127 &state.tenant_config,
128 )
129 .await
130 {
131 Ok(p) => p,
132 Err(e) => {
133 if let Err(del_err) = state.ath.db().delete_user(user.id).await {
137 tracing::error!(
138 user_id = %user.id, error = %del_err,
139 "dashboard_signup: rollback delete_user failed; orphan user"
140 );
141 }
142 return Err(DashboardSignupError::ProvisionFailed(e));
143 }
144 };
145
146 let outcome = state
149 .ath
150 .create_session_cookie(user.id)
151 .await
152 .map_err(DashboardSignupError::Auth)?;
153
154 Ok(DashboardSignupResult {
155 user: outcome.user,
156 provision,
157 set_cookie: outcome.set_cookie,
158 })
159}
160
161#[derive(Debug, thiserror::Error)]
163pub enum ProvisionForUserError {
164 #[error("dashboard user not found")]
165 UserNotFound,
166 #[error(transparent)]
167 Provision(#[from] SaasError),
168 #[error(transparent)]
169 Auth(#[from] AuthError),
170}
171
172pub async fn provision_tenant_for_user(
178 state: &DashboardState,
179 user_id: UserId,
180 tenant_name: String,
181 tenant_slug: String,
182) -> Result<ProvisionResult, ProvisionForUserError> {
183 let user = state
184 .ath
185 .db()
186 .get_user(user_id)
187 .await
188 .map_err(|e| match e {
189 AuthError::NotFound => ProvisionForUserError::UserNotFound,
190 other => ProvisionForUserError::Auth(other),
191 })?;
192
193 let result = state
194 .control_db
195 .provision_tenant(
196 tenant_name,
197 tenant_slug,
198 user.email.as_str().to_owned(),
199 &state.tenant_data_dir,
200 &state.tenant_config,
201 )
202 .await?;
203 Ok(result)
204}
205
206pub struct RegisterForInviteResult {
208 pub user: allowthem_core::User,
210 pub set_cookie: String,
212}
213
214pub async fn dashboard_register_for_invite(
225 state: &DashboardState,
226 email: String,
227 password: String,
228) -> Result<RegisterForInviteResult, DashboardSignupError> {
229 let email_obj = Email::new(email).map_err(|_| DashboardSignupError::InvalidEmail)?;
230 let user = state
231 .ath
232 .db()
233 .create_user(email_obj, &password, None, None)
234 .await
235 .map_err(|e| match e {
236 AuthError::Conflict(ref msg) if msg.contains("email") => {
237 DashboardSignupError::EmailTaken
238 }
239 other => DashboardSignupError::Auth(other),
240 })?;
241 let outcome = state
242 .ath
243 .create_session_cookie(user.id)
244 .await
245 .map_err(DashboardSignupError::Auth)?;
246 Ok(RegisterForInviteResult {
247 user: outcome.user,
248 set_cookie: outcome.set_cookie,
249 })
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use allowthem_core::AllowThemBuilder;
256 use sqlx::SqlitePool;
257
258 async fn test_dashboard_state() -> (DashboardState, tempfile::TempDir) {
259 let dir = tempfile::tempdir().expect("tempdir");
260
261 let dashboard_pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
262 let ath = AllowThemBuilder::with_pool(dashboard_pool)
263 .mfa_key([1u8; 32])
264 .signing_key([2u8; 32])
265 .csrf_key([3u8; 32])
266 .base_url("https://example.com")
267 .cookie_name(dashboard_cookie_name(false))
268 .cookie_secure(false)
269 .build()
270 .await
271 .unwrap();
272
273 let control_pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
274 let control_db = Arc::new(ControlDb::new(control_pool).await.unwrap());
275
276 let state = DashboardState {
277 ath,
278 control_db,
279 tenant_data_dir: dir.path().to_path_buf(),
280 tenant_config: Arc::new(TenantBuilderConfig {
281 mfa_key: [1u8; 32],
282 signing_key: [2u8; 32],
283 csrf_key: [3u8; 32],
284 base_domain: "example.com".into(),
285 is_production: false,
286 email_sender: None,
287 event_sink: None,
288 event_sink_factory: None,
289 mau_sink: None,
290 email_sender_factory: None,
291 }),
292 handle_cache: HandleCache::new(10),
293 is_production: false,
294 };
295 (state, dir)
296 }
297
298 #[test]
299 fn dashboard_cookie_name_prod_uses_host_prefix() {
300 assert_eq!(
301 dashboard_cookie_name(true),
302 "__Host-allowthem_dashboard_session"
303 );
304 }
305
306 #[test]
307 fn dashboard_cookie_name_dev_drops_prefix() {
308 assert_eq!(dashboard_cookie_name(false), "allowthem_dashboard_session");
309 }
310
311 #[tokio::test]
312 async fn signup_happy_path() {
313 let (state, _dir) = test_dashboard_state().await;
314 let result = dashboard_signup(
315 &state,
316 DashboardSignupParams {
317 email: "owner@acme.com".into(),
318 password: "supersecret".into(),
319 tenant_name: "Acme".into(),
320 tenant_slug: "acme".into(),
321 },
322 )
323 .await
324 .expect("signup");
325
326 let email = Email::new("owner@acme.com".into()).unwrap();
328 let dashboard_user = state.ath.db().get_user_by_email(&email).await.unwrap();
329 assert_eq!(dashboard_user.id, result.user.id);
330
331 let tenant = state
333 .control_db
334 .tenant_by_slug("acme")
335 .await
336 .unwrap()
337 .unwrap();
338 let (role, accepted_at): (String, Option<String>) = sqlx::query_as(
339 "SELECT role, accepted_at FROM tenant_members WHERE tenant_id = ?1 AND email = ?2",
340 )
341 .bind(tenant.id.as_slice())
342 .bind("owner@acme.com")
343 .fetch_one(state.control_db.pool())
344 .await
345 .unwrap();
346 assert_eq!(role, "owner");
347 assert!(accepted_at.is_some());
348
349 assert!(result.set_cookie.contains("allowthem_dashboard_session="));
351 }
352
353 #[tokio::test]
354 async fn signup_slug_conflict_compensates_dashboard_user() {
355 let (state, _dir) = test_dashboard_state().await;
356
357 dashboard_signup(
361 &state,
362 DashboardSignupParams {
363 email: "first@acme.com".into(),
364 password: "supersecret".into(),
365 tenant_name: "Acme".into(),
366 tenant_slug: "acme".into(),
367 },
368 )
369 .await
370 .expect("first signup");
371
372 let result = dashboard_signup(
373 &state,
374 DashboardSignupParams {
375 email: "second@acme.com".into(),
376 password: "supersecret".into(),
377 tenant_name: "Acme Two".into(),
378 tenant_slug: "acme".into(), },
380 )
381 .await;
382 let Err(err) = result else {
383 panic!("second signup must fail");
384 };
385 assert!(matches!(
386 err,
387 DashboardSignupError::ProvisionFailed(SaasError::SlugTaken)
388 ));
389
390 let email = Email::new("second@acme.com".into()).unwrap();
392 let res = state.ath.db().get_user_by_email(&email).await;
393 assert!(matches!(res, Err(AuthError::NotFound)));
394
395 let tenants = state.control_db.list_tenants().await.unwrap();
397 assert_eq!(tenants.len(), 1);
398 }
399
400 #[tokio::test]
401 async fn signup_email_taken() {
402 let (state, _dir) = test_dashboard_state().await;
403
404 let email = Email::new("dup@acme.com".into()).unwrap();
406 state
407 .ath
408 .db()
409 .create_user(email, "pw123456", None, None)
410 .await
411 .unwrap();
412
413 let result = dashboard_signup(
414 &state,
415 DashboardSignupParams {
416 email: "dup@acme.com".into(),
417 password: "anotherpw".into(),
418 tenant_name: "Dup".into(),
419 tenant_slug: "dup".into(),
420 },
421 )
422 .await;
423 let Err(err) = result else {
424 panic!("expected EmailTaken");
425 };
426 assert!(matches!(err, DashboardSignupError::EmailTaken));
427
428 let tenants = state.control_db.list_tenants().await.unwrap();
430 assert!(tenants.is_empty());
431 }
432
433 #[tokio::test]
434 async fn signup_invalid_email() {
435 let (state, _dir) = test_dashboard_state().await;
436 let result = dashboard_signup(
437 &state,
438 DashboardSignupParams {
439 email: "not-an-email".into(),
440 password: "supersecret".into(),
441 tenant_name: "X".into(),
442 tenant_slug: "xyz".into(),
443 },
444 )
445 .await;
446 let Err(err) = result else {
447 panic!("expected InvalidEmail");
448 };
449 assert!(matches!(err, DashboardSignupError::InvalidEmail));
450 }
451
452 #[tokio::test]
453 async fn provision_tenant_for_user_happy_path() {
454 let (state, _dir) = test_dashboard_state().await;
455 let email = Email::new("alice@acme.com".into()).unwrap();
456 let user = state
457 .ath
458 .db()
459 .create_user(email, "supersecret", None, None)
460 .await
461 .unwrap();
462
463 let result =
464 provision_tenant_for_user(&state, user.id, "Alice Co".into(), "alice-co".into())
465 .await
466 .expect("provision_tenant_for_user");
467
468 assert_eq!(result.tenant.slug, "alice-co");
469 assert_eq!(result.tenant.owner_email, "alice@acme.com");
470 }
471
472 #[tokio::test]
473 async fn provision_tenant_for_user_slug_taken_propagates() {
474 let (state, _dir) = test_dashboard_state().await;
475 let email = Email::new("alice@acme.com".into()).unwrap();
476 let user = state
477 .ath
478 .db()
479 .create_user(email, "supersecret", None, None)
480 .await
481 .unwrap();
482
483 provision_tenant_for_user(&state, user.id, "Alice".into(), "alice".into())
485 .await
486 .expect("first provision");
487
488 let result =
490 provision_tenant_for_user(&state, user.id, "Alice 2".into(), "alice".into()).await;
491 let Err(err) = result else {
492 panic!("expected slug-taken error");
493 };
494 assert!(matches!(
495 err,
496 ProvisionForUserError::Provision(SaasError::SlugTaken)
497 ));
498 }
499
500 #[tokio::test]
501 async fn provision_tenant_for_user_user_not_found() {
502 let (state, _dir) = test_dashboard_state().await;
503 let result =
504 provision_tenant_for_user(&state, UserId::new(), "Ghost".into(), "ghost".into()).await;
505 let Err(err) = result else {
506 panic!("expected UserNotFound");
507 };
508 assert!(matches!(err, ProvisionForUserError::UserNotFound));
509 }
510}