zitadel-tui 0.1.5

A terminal UI for managing Zitadel resources
use serde_json::Value;

use crate::tui::Record;

pub(crate) fn map_app_record(app: Value) -> Record {
    let (kind, summary, detail) = if let Some(oidc_config) = app.get("oidcConfig") {
        let kind = classify_oidc_kind(Some(oidc_config));
        let client_id = oidc_config
            .get("clientId")
            .and_then(|value| value.as_str())
            .unwrap_or("missing-client-id")
            .to_string();
        let redirect_count = oidc_config
            .get("redirectUris")
            .and_then(|value| value.as_array())
            .map(|uris| uris.len())
            .unwrap_or(0);

        (kind, format!("{redirect_count} redirects"), client_id)
    } else if let Some(api_config) = app.get("apiConfig") {
        let client_id = api_config
            .get("clientId")
            .and_then(|value| value.as_str())
            .unwrap_or("missing-client-id")
            .to_string();
        let auth_method = api_config
            .get("authMethodType")
            .and_then(|value| value.as_str())
            .unwrap_or("unknown-auth-method")
            .to_string();

        ("api".to_string(), auth_method, client_id)
    } else {
        (
            "unknown".to_string(),
            "0 redirects".to_string(),
            "missing-client-id".to_string(),
        )
    };
    Record {
        id: string_field(&app, "id", "missing-id"),
        name: string_field(&app, "name", "unnamed"),
        kind,
        summary,
        detail,
        changed_at: string_field(&app, "state", "unknown"),
    }
}

fn classify_oidc_kind(oidc_config: Option<&Value>) -> String {
    let Some(oidc) = oidc_config else {
        return "unknown".to_string();
    };

    if has_grant_type(oidc, "OIDC_GRANT_TYPE_DEVICE_CODE") {
        return "device-code".to_string();
    }

    if string_field_value(oidc, "appType") == Some("OIDC_APP_TYPE_NATIVE") {
        return "native".to_string();
    }

    if let Some(auth_method_type) = string_field_value(oidc, "authMethodType") {
        if auth_method_type == "OIDC_AUTH_METHOD_TYPE_NONE" {
            return "public".to_string();
        }

        return "confidential".to_string();
    }

    match string_field_value(oidc, "appType") {
        Some("OIDC_APP_TYPE_USER_AGENT") => "public".to_string(),
        Some("OIDC_APP_TYPE_WEB") => "confidential".to_string(),
        Some(_) => "oidc".to_string(),
        None if has_useful_oidc_fields(oidc) => "oidc".to_string(),
        None => "unknown".to_string(),
    }
}

fn has_grant_type(oidc: &Value, grant_type: &str) -> bool {
    oidc.get("grantTypes")
        .and_then(|value| value.as_array())
        .is_some_and(|grant_types| {
            grant_types
                .iter()
                .filter_map(|value| value.as_str())
                .any(|value| value == grant_type)
        })
}

fn has_useful_oidc_fields(oidc: &Value) -> bool {
    has_string_field(oidc, "clientId")
        || has_non_empty_array_field(oidc, "redirectUris")
        || has_non_empty_array_field(oidc, "grantTypes")
}

fn has_string_field(value: &Value, key: &str) -> bool {
    string_field_value(value, key).is_some_and(|value| !value.is_empty())
}

fn has_non_empty_array_field(value: &Value, key: &str) -> bool {
    value
        .get(key)
        .and_then(|field| field.as_array())
        .is_some_and(|values| !values.is_empty())
}

fn string_field_value<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
    value.get(key).and_then(|field| field.as_str())
}

pub(crate) fn map_user_record(user: Value) -> Record {
    let is_machine = user.get("machine").is_some();
    let user_type = if is_machine { "machine" } else { "human" };
    let state = string_field(&user, "state", "unknown");
    let name = if is_machine {
        user.get("machine")
            .and_then(|machine| machine.get("name"))
            .and_then(|name| name.as_str())
            .unwrap_or_else(|| {
                user.get("userName")
                    .and_then(|user_name| user_name.as_str())
                    .unwrap_or("unknown-user")
            })
            .to_string()
    } else {
        string_field(&user, "userName", "unknown-user")
    };
    let summary = if is_machine {
        user.get("preferredLoginName")
            .and_then(|login| login.as_str())
            .or_else(|| {
                user.get("loginNames")
                    .and_then(|login_names| login_names.as_array())
                    .and_then(|login_names| login_names.first())
                    .and_then(|login| login.as_str())
            })
            .or_else(|| {
                user.get("userName")
                    .and_then(|user_name| user_name.as_str())
            })
            .unwrap_or("no login")
            .to_string()
    } else {
        user.get("human")
            .and_then(|human| human.get("email"))
            .and_then(|email| email.get("email"))
            .and_then(|email| email.as_str())
            .unwrap_or("no email")
            .to_string()
    };
    let detail = if is_machine {
        "machine user".to_string()
    } else {
        user.get("human")
            .and_then(|human| human.get("profile"))
            .and_then(|profile| profile.get("displayName"))
            .and_then(|display_name| display_name.as_str())
            .unwrap_or("human user")
            .to_string()
    };

    Record {
        id: string_field(&user, "id", "missing-id"),
        name,
        kind: format!("{user_type} {state}"),
        summary,
        detail,
        changed_at: "loaded".to_string(),
    }
}

pub(crate) fn map_idp_record(idp: Value) -> Record {
    Record {
        id: string_field(&idp, "id", "missing-id"),
        name: string_field(&idp, "name", "unnamed-idp"),
        kind: string_field(&idp, "state", "unknown"),
        summary: string_field(&idp, "type", "provider"),
        detail: "manual credentials".to_string(),
        changed_at: "configured".to_string(),
    }
}

pub(crate) fn app_creation_summary(name: &str, value: &Value) -> String {
    format!(
        "{} -> {}",
        name,
        value
            .get("clientId")
            .and_then(|v| v.as_str())
            .unwrap_or("created")
    )
}

pub(crate) fn string_field(value: &Value, key: &str, fallback: &str) -> String {
    value
        .get(key)
        .and_then(|field| field.as_str())
        .unwrap_or(fallback)
        .to_string()
}