use std::path::PathBuf;
use std::sync::Arc;
use allowthem_core::error::AuthError;
use allowthem_core::types::UserId;
use allowthem_core::{AllowThem, Email, User};
use crate::cache::HandleCache;
use crate::control_db::ControlDb;
use crate::error::SaasError;
use crate::tenants::{ProvisionResult, TenantBuilderConfig};
pub fn dashboard_cookie_name(is_production: bool) -> &'static str {
if is_production {
"__Host-allowthem_dashboard_session"
} else {
"allowthem_dashboard_session"
}
}
#[derive(Clone)]
pub struct DashboardState {
pub ath: AllowThem,
pub control_db: Arc<ControlDb>,
pub tenant_data_dir: PathBuf,
pub tenant_config: Arc<TenantBuilderConfig>,
pub handle_cache: HandleCache,
pub is_production: bool,
}
pub struct DashboardSignupParams {
pub email: String,
pub password: String,
pub tenant_name: String,
pub tenant_slug: String,
}
pub struct DashboardSignupResult {
pub user: User,
pub provision: ProvisionResult,
pub set_cookie: String,
}
#[derive(Debug, thiserror::Error)]
pub enum DashboardSignupError {
#[error("invalid email")]
InvalidEmail,
#[error("email already in use")]
EmailTaken,
#[error("provision failed: {0}")]
ProvisionFailed(SaasError),
#[error(transparent)]
Auth(AuthError),
}
pub async fn dashboard_signup(
state: &DashboardState,
params: DashboardSignupParams,
) -> Result<DashboardSignupResult, DashboardSignupError> {
let email = Email::new(params.email.clone()).map_err(|_| DashboardSignupError::InvalidEmail)?;
let user = state
.ath
.db()
.create_user(email.clone(), ¶ms.password, None, None)
.await
.map_err(|e| match e {
AuthError::Conflict(ref msg) if msg.contains("email") => {
DashboardSignupError::EmailTaken
}
other => DashboardSignupError::Auth(other),
})?;
let provision = match state
.control_db
.provision_tenant(
params.tenant_name,
params.tenant_slug,
email.as_str().to_owned(),
&state.tenant_data_dir,
&state.tenant_config,
)
.await
{
Ok(p) => p,
Err(e) => {
if let Err(del_err) = state.ath.db().delete_user(user.id).await {
tracing::error!(
user_id = %user.id, error = %del_err,
"dashboard_signup: rollback delete_user failed; orphan user"
);
}
return Err(DashboardSignupError::ProvisionFailed(e));
}
};
let outcome = state
.ath
.create_session_cookie(user.id)
.await
.map_err(DashboardSignupError::Auth)?;
Ok(DashboardSignupResult {
user: outcome.user,
provision,
set_cookie: outcome.set_cookie,
})
}
#[derive(Debug, thiserror::Error)]
pub enum ProvisionForUserError {
#[error("dashboard user not found")]
UserNotFound,
#[error(transparent)]
Provision(#[from] SaasError),
#[error(transparent)]
Auth(#[from] AuthError),
}
pub async fn provision_tenant_for_user(
state: &DashboardState,
user_id: UserId,
tenant_name: String,
tenant_slug: String,
) -> Result<ProvisionResult, ProvisionForUserError> {
let user = state
.ath
.db()
.get_user(user_id)
.await
.map_err(|e| match e {
AuthError::NotFound => ProvisionForUserError::UserNotFound,
other => ProvisionForUserError::Auth(other),
})?;
let result = state
.control_db
.provision_tenant(
tenant_name,
tenant_slug,
user.email.as_str().to_owned(),
&state.tenant_data_dir,
&state.tenant_config,
)
.await?;
Ok(result)
}
pub struct RegisterForInviteResult {
pub user: allowthem_core::User,
pub set_cookie: String,
}
pub async fn dashboard_register_for_invite(
state: &DashboardState,
email: String,
password: String,
) -> Result<RegisterForInviteResult, DashboardSignupError> {
let email_obj = Email::new(email).map_err(|_| DashboardSignupError::InvalidEmail)?;
let user = state
.ath
.db()
.create_user(email_obj, &password, None, None)
.await
.map_err(|e| match e {
AuthError::Conflict(ref msg) if msg.contains("email") => {
DashboardSignupError::EmailTaken
}
other => DashboardSignupError::Auth(other),
})?;
let outcome = state
.ath
.create_session_cookie(user.id)
.await
.map_err(DashboardSignupError::Auth)?;
Ok(RegisterForInviteResult {
user: outcome.user,
set_cookie: outcome.set_cookie,
})
}
#[cfg(test)]
mod tests {
use super::*;
use allowthem_core::AllowThemBuilder;
use sqlx::SqlitePool;
async fn test_dashboard_state() -> (DashboardState, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let dashboard_pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
let ath = AllowThemBuilder::with_pool(dashboard_pool)
.mfa_key([1u8; 32])
.signing_key([2u8; 32])
.csrf_key([3u8; 32])
.base_url("https://example.com")
.cookie_name(dashboard_cookie_name(false))
.cookie_secure(false)
.build()
.await
.unwrap();
let control_pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
let control_db = Arc::new(ControlDb::new(control_pool).await.unwrap());
let state = DashboardState {
ath,
control_db,
tenant_data_dir: dir.path().to_path_buf(),
tenant_config: Arc::new(TenantBuilderConfig {
mfa_key: [1u8; 32],
signing_key: [2u8; 32],
csrf_key: [3u8; 32],
base_domain: "example.com".into(),
is_production: false,
email_sender: None,
event_sink: None,
event_sink_factory: None,
mau_sink: None,
email_sender_factory: None,
}),
handle_cache: HandleCache::new(10),
is_production: false,
};
(state, dir)
}
#[test]
fn dashboard_cookie_name_prod_uses_host_prefix() {
assert_eq!(
dashboard_cookie_name(true),
"__Host-allowthem_dashboard_session"
);
}
#[test]
fn dashboard_cookie_name_dev_drops_prefix() {
assert_eq!(dashboard_cookie_name(false), "allowthem_dashboard_session");
}
#[tokio::test]
async fn signup_happy_path() {
let (state, _dir) = test_dashboard_state().await;
let result = dashboard_signup(
&state,
DashboardSignupParams {
email: "owner@acme.com".into(),
password: "supersecret".into(),
tenant_name: "Acme".into(),
tenant_slug: "acme".into(),
},
)
.await
.expect("signup");
let email = Email::new("owner@acme.com".into()).unwrap();
let dashboard_user = state.ath.db().get_user_by_email(&email).await.unwrap();
assert_eq!(dashboard_user.id, result.user.id);
let tenant = state
.control_db
.tenant_by_slug("acme")
.await
.unwrap()
.unwrap();
let (role, accepted_at): (String, Option<String>) = sqlx::query_as(
"SELECT role, accepted_at FROM tenant_members WHERE tenant_id = ?1 AND email = ?2",
)
.bind(tenant.id.as_slice())
.bind("owner@acme.com")
.fetch_one(state.control_db.pool())
.await
.unwrap();
assert_eq!(role, "owner");
assert!(accepted_at.is_some());
assert!(result.set_cookie.contains("allowthem_dashboard_session="));
}
#[tokio::test]
async fn signup_slug_conflict_compensates_dashboard_user() {
let (state, _dir) = test_dashboard_state().await;
dashboard_signup(
&state,
DashboardSignupParams {
email: "first@acme.com".into(),
password: "supersecret".into(),
tenant_name: "Acme".into(),
tenant_slug: "acme".into(),
},
)
.await
.expect("first signup");
let result = dashboard_signup(
&state,
DashboardSignupParams {
email: "second@acme.com".into(),
password: "supersecret".into(),
tenant_name: "Acme Two".into(),
tenant_slug: "acme".into(), },
)
.await;
let Err(err) = result else {
panic!("second signup must fail");
};
assert!(matches!(
err,
DashboardSignupError::ProvisionFailed(SaasError::SlugTaken)
));
let email = Email::new("second@acme.com".into()).unwrap();
let res = state.ath.db().get_user_by_email(&email).await;
assert!(matches!(res, Err(AuthError::NotFound)));
let tenants = state.control_db.list_tenants().await.unwrap();
assert_eq!(tenants.len(), 1);
}
#[tokio::test]
async fn signup_email_taken() {
let (state, _dir) = test_dashboard_state().await;
let email = Email::new("dup@acme.com".into()).unwrap();
state
.ath
.db()
.create_user(email, "pw123456", None, None)
.await
.unwrap();
let result = dashboard_signup(
&state,
DashboardSignupParams {
email: "dup@acme.com".into(),
password: "anotherpw".into(),
tenant_name: "Dup".into(),
tenant_slug: "dup".into(),
},
)
.await;
let Err(err) = result else {
panic!("expected EmailTaken");
};
assert!(matches!(err, DashboardSignupError::EmailTaken));
let tenants = state.control_db.list_tenants().await.unwrap();
assert!(tenants.is_empty());
}
#[tokio::test]
async fn signup_invalid_email() {
let (state, _dir) = test_dashboard_state().await;
let result = dashboard_signup(
&state,
DashboardSignupParams {
email: "not-an-email".into(),
password: "supersecret".into(),
tenant_name: "X".into(),
tenant_slug: "xyz".into(),
},
)
.await;
let Err(err) = result else {
panic!("expected InvalidEmail");
};
assert!(matches!(err, DashboardSignupError::InvalidEmail));
}
#[tokio::test]
async fn provision_tenant_for_user_happy_path() {
let (state, _dir) = test_dashboard_state().await;
let email = Email::new("alice@acme.com".into()).unwrap();
let user = state
.ath
.db()
.create_user(email, "supersecret", None, None)
.await
.unwrap();
let result =
provision_tenant_for_user(&state, user.id, "Alice Co".into(), "alice-co".into())
.await
.expect("provision_tenant_for_user");
assert_eq!(result.tenant.slug, "alice-co");
assert_eq!(result.tenant.owner_email, "alice@acme.com");
}
#[tokio::test]
async fn provision_tenant_for_user_slug_taken_propagates() {
let (state, _dir) = test_dashboard_state().await;
let email = Email::new("alice@acme.com".into()).unwrap();
let user = state
.ath
.db()
.create_user(email, "supersecret", None, None)
.await
.unwrap();
provision_tenant_for_user(&state, user.id, "Alice".into(), "alice".into())
.await
.expect("first provision");
let result =
provision_tenant_for_user(&state, user.id, "Alice 2".into(), "alice".into()).await;
let Err(err) = result else {
panic!("expected slug-taken error");
};
assert!(matches!(
err,
ProvisionForUserError::Provision(SaasError::SlugTaken)
));
}
#[tokio::test]
async fn provision_tenant_for_user_user_not_found() {
let (state, _dir) = test_dashboard_state().await;
let result =
provision_tenant_for_user(&state, UserId::new(), "Ghost".into(), "ghost".into()).await;
let Err(err) = result else {
panic!("expected UserNotFound");
};
assert!(matches!(err, ProvisionForUserError::UserNotFound));
}
}