use askama::Template; use askama_web::WebTemplate; use axum::extract::State; use axum::http::HeaderMap; use axum::response::{AppendHeaders, IntoResponse, Redirect, Response}; use axum::Form; use serde::Deserialize; use sqlx::FromRow; use tower_sessions::Session; use crate::auth_guard::AuthUser; use crate::flash; use crate::htmx; 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, headers: HeaderMap, AuthUser(user): AuthUser, ) -> 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 ); let page = PageContext::from_request(&headers, &state.announcement); let clear = page.flash.is_some(); let template = ProfileTemplate { page, 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, password_url, content_expires: row.content_expires, }; if clear { Ok((AppendHeaders([flash::clear_flash()]), template).into_response()) } else { Ok(template.into_response()) } } #[derive(Deserialize)] pub struct ProfileForm { display_name: String, email: String, #[serde(default)] content_expires: Option, } #[derive(Template)] #[template(path = "partials/flash_fragment.html")] struct FlashFragment { css_class: &'static str, text: &'static str, } pub async fn update( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, session: Session, Form(form): Form, ) -> Response { 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}"); return flash_response(&headers, "email_update_failed", "/profile"); } } 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}"); return flash_response(&headers, "profile_save_failed", "/profile"); } 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}"); } flash_response(&headers, "profile_saved", "/profile") } fn flash_response(headers: &HeaderMap, code: &str, redirect_url: &str) -> Response { if htmx::is_htmx(headers) { if let Some(msg) = flash::resolve(code) { let fragment = FlashFragment { css_class: msg.css_class(), text: msg.text }; return axum::response::Html(fragment.render().unwrap_or_default()).into_response(); } "".into_response() } else { (AppendHeaders([flash::set_flash(code)]), Redirect::to(redirect_url)).into_response() } } 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(()) }