use askama::Template; use askama_web::WebTemplate; use axum::extract::State; use axum::http::HeaderMap; use axum::response::{AppendHeaders, IntoResponse, Redirect}; use axum::Form; use serde::Deserialize; use sqlx::FromRow; use tower_sessions::Session; use crate::auth_guard::AuthUser; use crate::flash; use crate::page::PageContext; 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 page: PageContext, pub display_name: String, pub email: String, pub plan: String, pub sub: String, pub created_at: String, pub password_url: String, pub content_expires: bool, } 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, content_expires 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, content_expires: row.content_expires, }) } #[derive(Deserialize)] pub struct ProfileForm { display_name: String, email: String, #[serde(default)] content_expires: Option, } 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 content_expires = form.content_expires.is_some(); 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, content_expires = $3 WHERE keycloak_sub = $4", ) .bind(display_name_val) .bind(&new_email) .bind(content_expires) .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 plan_str = user.plan.as_deref().unwrap_or("free"); if let Err(e) = update_keycloak_user_attributes( &state, &user.sub, plan_str, content_expires, ) .await { tracing::warn!("keycloak attribute sync failed: {e}"); } 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: Some(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()) } pub(crate) async fn update_keycloak_user_attributes( state: &AppState, sub: &str, plan: &str, content_expires: bool, ) -> 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!({ "attributes": { "plan": [plan], "content_expires": [content_expires.to_string()] } })) .send() .await .map_err(|e| format!("attribute update request failed: {e}"))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(format!("attribute update returned {status}: {body}")); } Ok(()) } 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(()) }