Skip to main content

alibi_api/plugins/user_management/
update.rs

1use alibi_core::entity::AuthUser;
2use alibi_core::utils::username::UsernameConfig;
3use alibi_core::{
4    AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, ErrorCodeMessageResponse,
5    UpdateUser, UpdateUserRequest,
6};
7
8/// Handle user profile update.
9#[expect(
10    clippy::too_many_lines,
11    reason = "Keep field validation and user-update callbacks adjacent to the persistence operation"
12)]
13pub async fn handle_update_user<S: alibi_core::AuthSchema>(
14    req: &AuthRequest,
15    context: &AuthContext<S>,
16) -> AuthResult<AuthResponse> {
17    let (current_user, current_session) =
18        context.require_cached_session(req).await.map_err(|error| {
19            if matches!(
20                error,
21                AuthError::Unauthenticated | AuthError::SessionNotFound | AuthError::UserNotFound
22            ) {
23                AuthError::Upstream {
24                    status: 401,
25                    code: "UNAUTHORIZED",
26                    message: "Unauthorized",
27                }
28            } else {
29                error
30            }
31        })?;
32    let body: serde_json::Value = req
33        .body_as_json()
34        .map_err(|e| AuthError::bad_request(format!("Invalid JSON: {e}")))?;
35    let Some(body) = body.as_object() else {
36        let actual = match &body {
37            serde_json::Value::Null => "null",
38            serde_json::Value::Bool(_) => "boolean",
39            serde_json::Value::Number(_) => "number",
40            serde_json::Value::String(_) => "string",
41            serde_json::Value::Array(_) => "array",
42            serde_json::Value::Object(_) => "record",
43        };
44        return Ok(AuthResponse::json(
45            400,
46            &ErrorCodeMessageResponse {
47                code: Some("VALIDATION_ERROR".to_owned()),
48                message: format!("[body] Invalid input: expected record, received {actual}"),
49            },
50        )?);
51    };
52
53    if body.contains_key("email") {
54        return Err(AuthError::bad_request("Email can not be updated"));
55    }
56
57    // Role changes require the administrator's authorization, independently
58    // of any application-defined additional field policy.
59    if body.contains_key("role") {
60        return Err(AuthError::Api {
61            status: 400,
62            code: Some("FIELD_NOT_ALLOWED".into()),
63            message: "role is not allowed to be set".into(),
64        });
65    }
66
67    let raw_body: alibi_core::utils::json::JsValue = req.body_as_json()?;
68    crate::plugins::last_login_method::reject_last_login_method_input(
69        context,
70        raw_body.get("lastLoginMethod"),
71    )?;
72
73    let update_req: UpdateUserRequest =
74        serde_json::from_value(serde_json::Value::Object(body.clone()))
75            .map_err(|e| AuthError::bad_request(format!("Invalid JSON: {e}")))?;
76    let policy = context.extensions.get::<UsernameConfig>();
77    if let Some(policy) = &policy {
78        if let Some(value) = &update_req.username {
79            policy.validate_hook_value(value).await?;
80            let normalized = policy.normalize(value)?;
81            let current_username = current_user.username();
82            if policy.immutable_username
83                && current_username
84                    .as_ref()
85                    .is_some_and(|value| !value.is_empty() && *value != normalized)
86            {
87                return Err(AuthError::Upstream {
88                    status: 400,
89                    code: "USERNAME_IS_IMMUTABLE",
90                    message: "Username cannot be updated",
91                });
92            }
93            if let Some(existing_user) = context.database.get_user_by_username(&normalized).await?
94                && existing_user.id() != current_user.id()
95            {
96                return Err(AuthError::Upstream {
97                    status: 400,
98                    code: "USERNAME_IS_ALREADY_TAKEN",
99                    message: "Username is already taken. Please try another.",
100                });
101            }
102        }
103        if let Some(value) = &update_req.display_username {
104            policy.validate_display(value).await?;
105        }
106    }
107    let mut input_fields = raw_body
108        .as_object()
109        .ok_or_else(|| AuthError::bad_request("Invalid JSON object"))?
110        .clone();
111    if policy.is_none() {
112        drop(input_fields.shift_remove("username"));
113    }
114    if policy
115        .as_ref()
116        .is_none_or(|policy| !policy.include_display_username)
117    {
118        drop(input_fields.shift_remove("displayUsername"));
119    }
120    let additional_fields = context
121        .parse_user_fields(&input_fields, false)
122        .map_err(|error| match error {
123            alibi_core::field_policy::FieldInputError::Validation { code, message } => {
124                AuthError::Api {
125                    status: 400,
126                    code: Some(code.into()),
127                    message,
128                }
129            }
130            alibi_core::field_policy::FieldInputError::Transform(error) => error,
131        })?;
132    let username = additional_fields
133        .get("username")
134        .and_then(alibi_core::utils::json::JsValue::as_str)
135        .map(str::to_owned);
136    let display_username = additional_fields
137        .get("displayUsername")
138        .and_then(alibi_core::utils::json::JsValue::as_str)
139        .map(str::to_owned);
140
141    let clear_phone = context
142        .get_metadata("phone-number.enabled")
143        .and_then(serde_json::Value::as_bool)
144        == Some(true)
145        && body.get("phoneNumber") == Some(&serde_json::Value::Null);
146    let has_changes = additional_fields.has_input_fields()
147        || clear_phone
148        || update_req.name.is_some()
149        || update_req.image.is_some()
150        || username.is_some()
151        || display_username.is_some()
152        || update_req.metadata.is_some();
153    if !has_changes {
154        return Err(AuthError::bad_request("No fields to update"));
155    }
156
157    let update_user = UpdateUser {
158        provider_email_verified: None,
159        provider_name: None,
160        provider_image: None,
161        additional_fields,
162        is_anonymous: None,
163        phone_number: clear_phone.then_some(None),
164        phone_number_verified: None,
165        last_login_method: None,
166        email: None,
167        name: update_req.name,
168        image: update_req.image,
169        email_verified: None,
170        username,
171        display_username,
172        role: None,
173        banned: None,
174        ban_reason: None,
175        ban_expires: None,
176        two_factor_enabled: None,
177        metadata: update_req.metadata,
178    };
179
180    let publication = match context
181        .database
182        .update_user_record(&current_user.id(), update_user.clone())
183        .await
184    {
185        Ok(updated_user) => alibi_core::CacheVersionContext::created(
186            updated_user.clone(),
187            current_session.clone(),
188            context.user_view(&updated_user),
189            current_session.clone(),
190        ),
191        Err(AuthError::UserNotFound) => {
192            // Source retains the authenticated output snapshot when the
193            // adapter no longer has this user. This does not recreate a row.
194            let mut user = context.user_view(&current_user);
195            if let Some(name) = update_user.name {
196                user.name = Some(name);
197            }
198            if let Some(image) = update_user.image {
199                user.image = Some(image);
200            }
201            if let Some(username) = update_user.username {
202                user.username = Some(username);
203            }
204            if let Some(display_username) = update_user.display_username {
205                user.display_username = Some(display_username);
206            }
207            if let Some(metadata) = update_user.metadata {
208                user.metadata = metadata;
209            }
210            for (name, value) in update_user.additional_fields {
211                drop(user.extension_fields.insert(name, value.to_json_value()?));
212            }
213            if let Some(phone_number) = update_user.phone_number {
214                user.phone_number = phone_number;
215                drop(
216                    user.extension_fields
217                        .insert("phoneNumber".into(), serde_json::Value::Null),
218                );
219            }
220            alibi_core::CacheVersionContext::created(
221                user.clone(),
222                current_session.clone(),
223                user,
224                current_session.clone(),
225            )
226        }
227        Err(error) => return Err(error),
228    };
229    alibi_core::session::cookie_cache::runtime::emit_issuance_snapshot(context, publication)
230        .await?;
231
232    let mut response = AuthResponse::json(200, &alibi_core::StatusResponse { status: true })?;
233
234    if let Some(token) = context.session_manager().extract_session_token(req) {
235        let cookie_header =
236            alibi_core::utils::cookie_utils::create_session_cookie(&token, &context.config)?;
237        response = response.with_header("Set-Cookie", cookie_header);
238    }
239
240    Ok(response)
241}