cedros-login-server 0.0.14

Authentication server for cedros-login with email/password, Google OAuth, and Solana wallet sign-in
Documentation
//! Setup handlers for first-run configuration
//!
//! These endpoints are used during initial setup before any admin user exists.
//! Once an admin is created, most of these endpoints become disabled.

use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;

use crate::callback::AuthCallback;
use crate::errors::AppError;
use crate::AppState;
use crate::models::AuthMethod;
use crate::repositories::{MembershipEntity, OrgEntity, OrgRole, SystemSetting, UserEntity};
use crate::services::EmailService;

/// Response for setup status check
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SetupStatusResponse {
    /// Whether initial setup is needed (no admin exists)
    pub needs_setup: bool,
    /// Whether at least one admin user exists
    pub has_admin: bool,
    /// Server version for compatibility checking
    pub server_version: String,
}

/// Request to create the first admin user
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateFirstAdminRequest {
    /// Admin email address
    pub email: String,
    /// Admin password (will be hashed)
    pub password: String,
    /// Optional display name
    pub name: Option<String>,
    /// Organization name (defaults to "My Organization")
    pub org_name: Option<String>,
}

/// Response after creating first admin
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateFirstAdminResponse {
    /// Whether admin was successfully created
    pub success: bool,
    /// Created user ID
    pub user_id: Uuid,
    /// Message for the user
    pub message: String,
}

/// Check if initial setup is required
///
/// GET /setup/status
///
/// This endpoint is always accessible (no auth required).
/// Returns whether the system needs initial setup (no admin exists).
pub async fn setup_status<C: AuthCallback, E: EmailService>(
    State(state): State<Arc<AppState<C, E>>>,
) -> Result<impl IntoResponse, AppError> {
    let has_admin = check_has_admin(&state).await?;

    Ok(Json(SetupStatusResponse {
        needs_setup: !has_admin,
        has_admin,
        server_version: env!("CARGO_PKG_VERSION").to_string(),
    }))
}

/// Create the first admin user
///
/// POST /setup/admin
///
/// This endpoint only works when no admin users exist.
/// After the first admin is created, this endpoint returns 403 Forbidden.
pub async fn create_first_admin<C: AuthCallback, E: EmailService>(
    State(state): State<Arc<AppState<C, E>>>,
    Json(req): Json<CreateFirstAdminRequest>,
) -> Result<impl IntoResponse, AppError> {
    // Check if admin already exists
    let has_admin = check_has_admin(&state).await?;
    if has_admin {
        return Err(AppError::Forbidden(
            "Setup already completed. An admin user already exists.".into(),
        ));
    }

    // Validate email format
    if !req.email.contains('@') || req.email.len() < 5 {
        return Err(AppError::Validation("Invalid email format".into()));
    }

    // Validate password strength
    if req.password.len() < 8 {
        return Err(AppError::Validation(
            "Password must be at least 8 characters".into(),
        ));
    }

    // Check if email already exists
    if let Some(_existing) = state.user_repo.find_by_email(&req.email).await? {
        return Err(AppError::Validation("Email already registered".into()));
    }

    // Hash password using the password service
    let password_hash = state.password_service.hash(req.password.clone()).await?;

    // Create user entity
    let user_id = Uuid::new_v4();
    let now = chrono::Utc::now();
    let user = UserEntity {
        id: user_id,
        email: Some(req.email.clone()),
        email_verified: true, // Auto-verify for setup admin
        password_hash: Some(password_hash),
        name: req.name,
        picture: None,
        wallet_address: None,
        google_id: None,
        apple_id: None,
        stripe_customer_id: None,
        auth_methods: vec![AuthMethod::Email],
        is_system_admin: true, // This is the key - make them admin
        created_at: now,
        updated_at: now,
        last_login_at: Some(now),
    };

    // Create the user
    state.user_repo.create(user).await?;

    // Create site organization
    let org_name = req.org_name.unwrap_or_else(|| "My Organization".to_string());
    let slug = slug_from_name(&org_name);
    let org = OrgEntity::new(org_name, slug, user_id, false);
    let org_id = org.id;
    state.org_repo.create(org).await?;

    // Store default_org_id so new users auto-join this org
    let setting = SystemSetting::new(
        "default_org_id".to_string(),
        org_id.to_string(),
        "org".to_string(),
    );
    state.system_settings_repo.upsert(setting).await?;

    // Create owner membership
    let membership = MembershipEntity::new(user_id, org_id, OrgRole::Owner);
    state.membership_repo.create(membership).await?;

    tracing::info!(
        user_id = %user_id,
        email = %req.email,
        "First admin user created during setup"
    );

    Ok((
        StatusCode::CREATED,
        Json(CreateFirstAdminResponse {
            success: true,
            user_id,
            message: "Admin account created successfully. You can now log in.".to_string(),
        }),
    ))
}

/// Check if any system admin users exist
async fn check_has_admin<C: AuthCallback, E: EmailService>(
    state: &Arc<AppState<C, E>>,
) -> Result<bool, AppError> {
    // Check for any user with is_system_admin = true
    let admin_count = state.user_repo.count_system_admins().await?;
    Ok(admin_count > 0)
}

/// Convert an org name to a URL-friendly slug
fn slug_from_name(name: &str) -> String {
    let slug: String = name
        .to_lowercase()
        .chars()
        .map(|c| if c.is_alphanumeric() { c } else { '-' })
        .collect();
    // Collapse multiple dashes and trim edges
    let mut result = String::new();
    for ch in slug.chars() {
        if ch == '-' && result.ends_with('-') {
            continue;
        }
        result.push(ch);
    }
    result.trim_matches('-').to_string()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_setup_status_serialization() {
        let status = SetupStatusResponse {
            needs_setup: true,
            has_admin: false,
            server_version: "1.0.0".to_string(),
        };
        let json = serde_json::to_string(&status).unwrap();
        assert!(json.contains("needsSetup"));
        assert!(json.contains("hasAdmin"));
    }

    #[test]
    fn test_create_admin_request_deserialization() {
        let json = r#"{"email":"admin@example.com","password":"secret123","name":"Admin"}"#;
        let req: CreateFirstAdminRequest = serde_json::from_str(json).unwrap();
        assert_eq!(req.email, "admin@example.com");
        assert_eq!(req.password, "secret123");
        assert_eq!(req.name, Some("Admin".to_string()));
        assert_eq!(req.org_name, None);
    }

    #[test]
    fn test_create_admin_request_with_org_name() {
        let json = r#"{"email":"admin@example.com","password":"secret123","orgName":"Acme Corp"}"#;
        let req: CreateFirstAdminRequest = serde_json::from_str(json).unwrap();
        assert_eq!(req.org_name, Some("Acme Corp".to_string()));
    }

    #[test]
    fn test_slug_from_name() {
        assert_eq!(slug_from_name("My Organization"), "my-organization");
        assert_eq!(slug_from_name("Acme Corp!"), "acme-corp");
        assert_eq!(slug_from_name("  Hello  World  "), "hello-world");
    }
}