use askama::Template; use askama_web::WebTemplate; use axum::extract::{Query, State}; use axum::response::Redirect; use axum::Form; use serde::Deserialize; use sqlx::FromRow; use tower_sessions::Session; use crate::auth_guard::AuthUser; use crate::state::AppState; use irc_now_common::auth::UserClaims; #[derive(FromRow)] struct ProfileRow { email: Option, display_name: Option, plan: String, created_at: String, content_expires: bool, } #[derive(Template, WebTemplate)] #[template(path = "profile.html")] pub struct ProfileTemplate { pub display_name: String, pub email: String, pub plan: String, pub sub: String, pub created_at: String, pub saved: bool, pub error: Option, pub password_url: String, pub content_expires: bool, } #[derive(Deserialize)] pub struct ProfileQuery { #[serde(default)] saved: Option, #[serde(default)] error: Option, } pub async fn index( State(state): State, AuthUser(user): AuthUser, Query(params): Query, ) -> Result { let row = sqlx::query_as::<_, ProfileRow>( "SELECT email, display_name, plan, to_char(created_at, 'YYYY-MM-DD') AS created_at FROM users WHERE keycloak_sub = $1", ) .bind(&user.sub) .fetch_one(&state.db) .await .map_err(|e| { tracing::error!("profile query failed: {e}"); Redirect::temporary("/dashboard") })?; let password_url = format!( "{}/account/#/security/signingin", state.oidc.issuer_url ); Ok(ProfileTemplate { display_name: row.display_name.unwrap_or_default(), email: row.email.unwrap_or_default(), plan: row.plan, sub: user.sub, created_at: row.created_at, saved: params.saved.as_deref() == Some("1"), error: params.error, password_url, }) } #[derive(Deserialize)] pub struct ProfileForm { display_name: String, email: String, } pub async fn update( State(state): State, AuthUser(user): AuthUser, session: Session, Form(form): Form, ) -> Redirect { let display_name = form.display_name.trim().to_string(); let new_email = form.email.trim().to_string(); let email_changed = user.email.as_deref() != Some(&new_email); if email_changed && !new_email.is_empty() { if let Err(e) = update_keycloak_email(&state, &user.sub, &new_email).await { tracing::error!("keycloak email update failed: {e}"); let msg = urlencoding::encode("failed to update email in identity provider"); return Redirect::to(&format!("/profile?error={msg}")); } } let display_name_val = if display_name.is_empty() { None } else { Some(display_name.as_str()) }; let result = sqlx::query( "UPDATE users SET display_name = $1, email = $2 WHERE keycloak_sub = $3", ) .bind(display_name_val) .bind(&new_email) .bind(&user.sub) .execute(&state.db) .await; if let Err(e) = result { tracing::error!("profile update failed: {e}"); let msg = urlencoding::encode("failed to save profile"); return Redirect::to(&format!("/profile?error={msg}")); } let updated_claims = UserClaims { sub: user.sub, email: if new_email.is_empty() { None } else { Some(new_email) }, plan: user.plan, stripe_customer_id: user.stripe_customer_id, display_name: if display_name.is_empty() { None } else { Some(display_name) }, content_expires: user.content_expires, }; if let Err(e) = session.insert("user", &updated_claims).await { tracing::error!("session update failed: {e}"); } Redirect::to("/profile?saved=1") } async fn get_keycloak_admin_token(state: &AppState) -> Result { let token_url = format!( "{}/protocol/openid-connect/token", state.oidc.issuer_url ); let resp = state .http_client .post(&token_url) .form(&[ ("grant_type", "client_credentials"), ("client_id", &state.oidc.client_id), ("client_secret", &state.oidc.client_secret), ]) .send() .await .map_err(|e| format!("token request failed: {e}"))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(format!("token request returned {status}: {body}")); } let body: serde_json::Value = resp .json() .await .map_err(|e| format!("token parse failed: {e}"))?; body["access_token"] .as_str() .map(String::from) .ok_or_else(|| "no access_token in response".to_string()) } async fn update_keycloak_email( state: &AppState, sub: &str, email: &str, ) -> Result<(), String> { let token = get_keycloak_admin_token(state).await?; let base_url = state .oidc .issuer_url .replace("/realms/irc-now", ""); let url = format!( "{}/admin/realms/irc-now/users/{}", base_url, sub ); let resp = state .http_client .put(&url) .bearer_auth(&token) .json(&serde_json::json!({ "email": email })) .send() .await .map_err(|e| format!("email update request failed: {e}"))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(format!("email update returned {status}: {body}")); } Ok(()) }