use alibi_core::entity::AuthUser;
use alibi_core::utils::username::UsernameConfig;
use alibi_core::{
AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, ErrorCodeMessageResponse,
UpdateUser, UpdateUserRequest,
};
#[expect(
clippy::too_many_lines,
reason = "Keep field validation and user-update callbacks adjacent to the persistence operation"
)]
pub async fn handle_update_user<S: alibi_core::AuthSchema>(
req: &AuthRequest,
context: &AuthContext<S>,
) -> AuthResult<AuthResponse> {
let (current_user, current_session) =
context.require_cached_session(req).await.map_err(|error| {
if matches!(
error,
AuthError::Unauthenticated | AuthError::SessionNotFound | AuthError::UserNotFound
) {
AuthError::Upstream {
status: 401,
code: "UNAUTHORIZED",
message: "Unauthorized",
}
} else {
error
}
})?;
let body: serde_json::Value = req
.body_as_json()
.map_err(|e| AuthError::bad_request(format!("Invalid JSON: {e}")))?;
let Some(body) = body.as_object() else {
let actual = match &body {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "boolean",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "record",
};
return Ok(AuthResponse::json(
400,
&ErrorCodeMessageResponse {
code: Some("VALIDATION_ERROR".to_owned()),
message: format!("[body] Invalid input: expected record, received {actual}"),
},
)?);
};
if body.contains_key("email") {
return Err(AuthError::bad_request("Email can not be updated"));
}
if body.contains_key("role") {
return Err(AuthError::Api {
status: 400,
code: Some("FIELD_NOT_ALLOWED".into()),
message: "role is not allowed to be set".into(),
});
}
let raw_body: alibi_core::utils::json::JsValue = req.body_as_json()?;
crate::plugins::last_login_method::reject_last_login_method_input(
context,
raw_body.get("lastLoginMethod"),
)?;
let update_req: UpdateUserRequest =
serde_json::from_value(serde_json::Value::Object(body.clone()))
.map_err(|e| AuthError::bad_request(format!("Invalid JSON: {e}")))?;
let policy = context.extensions.get::<UsernameConfig>();
if let Some(policy) = &policy {
if let Some(value) = &update_req.username {
policy.validate_hook_value(value).await?;
let normalized = policy.normalize(value)?;
let current_username = current_user.username();
if policy.immutable_username
&& current_username
.as_ref()
.is_some_and(|value| !value.is_empty() && *value != normalized)
{
return Err(AuthError::Upstream {
status: 400,
code: "USERNAME_IS_IMMUTABLE",
message: "Username cannot be updated",
});
}
if let Some(existing_user) = context.database.get_user_by_username(&normalized).await?
&& existing_user.id() != current_user.id()
{
return Err(AuthError::Upstream {
status: 400,
code: "USERNAME_IS_ALREADY_TAKEN",
message: "Username is already taken. Please try another.",
});
}
}
if let Some(value) = &update_req.display_username {
policy.validate_display(value).await?;
}
}
let mut input_fields = raw_body
.as_object()
.ok_or_else(|| AuthError::bad_request("Invalid JSON object"))?
.clone();
if policy.is_none() {
drop(input_fields.shift_remove("username"));
}
if policy
.as_ref()
.is_none_or(|policy| !policy.include_display_username)
{
drop(input_fields.shift_remove("displayUsername"));
}
let additional_fields = context
.parse_user_fields(&input_fields, false)
.map_err(|error| match error {
alibi_core::field_policy::FieldInputError::Validation { code, message } => {
AuthError::Api {
status: 400,
code: Some(code.into()),
message,
}
}
alibi_core::field_policy::FieldInputError::Transform(error) => error,
})?;
let username = additional_fields
.get("username")
.and_then(alibi_core::utils::json::JsValue::as_str)
.map(str::to_owned);
let display_username = additional_fields
.get("displayUsername")
.and_then(alibi_core::utils::json::JsValue::as_str)
.map(str::to_owned);
let clear_phone = context
.get_metadata("phone-number.enabled")
.and_then(serde_json::Value::as_bool)
== Some(true)
&& body.get("phoneNumber") == Some(&serde_json::Value::Null);
let has_changes = additional_fields.has_input_fields()
|| clear_phone
|| update_req.name.is_some()
|| update_req.image.is_some()
|| username.is_some()
|| display_username.is_some()
|| update_req.metadata.is_some();
if !has_changes {
return Err(AuthError::bad_request("No fields to update"));
}
let update_user = UpdateUser {
provider_email_verified: None,
provider_name: None,
provider_image: None,
additional_fields,
is_anonymous: None,
phone_number: clear_phone.then_some(None),
phone_number_verified: None,
last_login_method: None,
email: None,
name: update_req.name,
image: update_req.image,
email_verified: None,
username,
display_username,
role: None,
banned: None,
ban_reason: None,
ban_expires: None,
two_factor_enabled: None,
metadata: update_req.metadata,
};
let publication = match context
.database
.update_user_record(¤t_user.id(), update_user.clone())
.await
{
Ok(updated_user) => alibi_core::CacheVersionContext::created(
updated_user.clone(),
current_session.clone(),
context.user_view(&updated_user),
current_session.clone(),
),
Err(AuthError::UserNotFound) => {
let mut user = context.user_view(¤t_user);
if let Some(name) = update_user.name {
user.name = Some(name);
}
if let Some(image) = update_user.image {
user.image = Some(image);
}
if let Some(username) = update_user.username {
user.username = Some(username);
}
if let Some(display_username) = update_user.display_username {
user.display_username = Some(display_username);
}
if let Some(metadata) = update_user.metadata {
user.metadata = metadata;
}
for (name, value) in update_user.additional_fields {
drop(user.extension_fields.insert(name, value.to_json_value()?));
}
if let Some(phone_number) = update_user.phone_number {
user.phone_number = phone_number;
drop(
user.extension_fields
.insert("phoneNumber".into(), serde_json::Value::Null),
);
}
alibi_core::CacheVersionContext::created(
user.clone(),
current_session.clone(),
user,
current_session.clone(),
)
}
Err(error) => return Err(error),
};
alibi_core::session::cookie_cache::runtime::emit_issuance_snapshot(context, publication)
.await?;
let mut response = AuthResponse::json(200, &alibi_core::StatusResponse { status: true })?;
if let Some(token) = context.session_manager().extract_session_token(req) {
let cookie_header =
alibi_core::utils::cookie_utils::create_session_cookie(&token, &context.config)?;
response = response.with_header("Set-Cookie", cookie_header);
}
Ok(response)
}