alibi_plugins/user_management/
update.rs1use alibi_core::entity::AuthUser;
2use alibi_core::session::SessionRequest;
3use alibi_core::utils::json::JsValue;
4use alibi_core::utils::username::UsernameConfig;
5use alibi_core::{
6 AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, ErrorCodeMessageResponse,
7 UpdateUser, UpdateUserRequest,
8};
9
10pub fn update_user_endpoint(
15 body: &impl serde::Serialize,
16) -> AuthResult<alibi_core::endpoint::ServerEndpoint<alibi_core::StatusResponse>> {
17 alibi_core::endpoint::ServerEndpoint::new("core", "updateUser").with_body(body)
18}
19
20#[must_use]
22pub fn ok_endpoint() -> alibi_core::endpoint::ServerEndpoint<alibi_core::OkResponse> {
23 alibi_core::endpoint::ServerEndpoint::new("core", "ok")
24}
25
26#[must_use]
28pub fn error_endpoint() -> alibi_core::endpoint::ServerEndpoint<alibi_core::AuthResponse> {
29 alibi_core::endpoint::ServerEndpoint::new("core", "error")
30}
31
32#[doc(hidden)]
34pub struct BuiltinUserEndpoints;
35#[async_trait::async_trait]
36impl<S: alibi_core::AuthSchema> alibi_core::AuthPlugin<S> for BuiltinUserEndpoints {
37 fn name(&self) -> &'static str {
38 "core"
39 }
40 fn routes(&self) -> Vec<alibi_core::AuthRoute> {
41 Vec::new()
42 }
43 fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> {
44 vec![
45 crate::endpoint::definition("ok", "ok", Some("/ok"), alibi_core::HttpMethod::Get),
46 crate::endpoint::definition(
47 "error",
48 "error",
49 Some("/error"),
50 alibi_core::HttpMethod::Get,
51 ),
52 crate::endpoint::definition(
53 "updateUser",
54 "updateUser",
55 Some("/update-user"),
56 alibi_core::HttpMethod::Post,
57 ),
58 ]
59 }
60 fn validate_endpoint(
61 &self,
62 call: &alibi_core::endpoint::EndpointCall,
63 _: &AuthContext<S>,
64 ) -> AuthResult<alibi_core::endpoint::EndpointInput> {
65 if matches!(call.operation_id(), "ok" | "error") {
66 return Ok(alibi_core::endpoint::EndpointInput {
67 body: call.body().cloned(),
68 query: call.query().cloned(),
69 });
70 }
71 let body = call
72 .body()
73 .filter(|body| body.as_object().is_some())
74 .ok_or_else(|| {
75 crate::endpoint::validation(format!(
76 "[body] Invalid input: expected record, received {}",
77 crate::authentication_helpers::json_type(call.body())
78 ))
79 })?;
80 Ok(alibi_core::endpoint::EndpointInput {
81 body: Some(body.clone()),
82 query: call.query().cloned(),
83 })
84 }
85 async fn on_endpoint(
86 &self,
87 call: &alibi_core::endpoint::EndpointCall,
88 context: &AuthContext<S>,
89 ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
90 if call.operation_id() == "error" {
91 let url = call
92 .request()
93 .and_then(|request| request.url())
94 .ok_or_else(|| AuthError::internal("Invalid URL"))?;
95 let parameter = |name: &str| {
96 url.query_pairs()
97 .find_map(|(key, value)| (key == name).then(|| value.into_owned()))
98 };
99 let code = parameter("error").unwrap_or_else(|| "UNKNOWN".into());
100 let description = parameter("error_description");
101 let response = alibi_core::error::page::error_page_response(
102 &context.config,
103 &code,
104 description.as_deref().filter(|value| !value.is_empty()),
105 )?;
106 return Ok(alibi_core::endpoint::EndpointResponse::from_http_response(
107 response,
108 ));
109 }
110 if call.operation_id() == "ok" {
111 return alibi_core::endpoint::EndpointResponse::json(&alibi_core::OkResponse {
112 ok: true,
113 });
114 }
115 let body = call
116 .body()
117 .ok_or_else(|| AuthError::bad_request("Missing profile input"))?;
118 let (user, session) = context
119 .require_cached_session(call)
120 .await
121 .map_err(crate::helpers::unauthorized_if_session_missing)?;
122 call.record_authenticated_session(context.user_view(&user), session.clone());
123 let response = update_user_core(call, body, user, session, context).await?;
124 if response.status >= 400 {
125 return Err(crate::endpoint::error_response(response));
126 }
127 let mut result = alibi_core::endpoint::EndpointResponse::value(
130 alibi_core::utils::json::from_slice(&response.body)?,
131 );
132 for (name, value) in response.headers {
133 if !name.eq_ignore_ascii_case("content-type") {
134 result = result.with_header(name, value);
135 }
136 }
137 Ok(result)
138 }
139 async fn on_request(
140 &self,
141 _: &AuthRequest,
142 _: &AuthContext<S>,
143 ) -> AuthResult<Option<AuthResponse>> {
144 Ok(None)
145 }
146}
147
148pub async fn handle_update_user<S: alibi_core::AuthSchema>(
153 req: &AuthRequest,
154 context: &AuthContext<S>,
155) -> AuthResult<AuthResponse> {
156 let (user, session) = context
158 .require_cached_session(req)
159 .await
160 .map_err(crate::helpers::unauthorized_if_session_missing)?;
161 _ = req
162 .body_as_json::<serde_json::Value>()
163 .map_err(|error| AuthError::bad_request(format!("Invalid JSON: {error}")))?;
164 let raw_body: JsValue = req.body_as_json()?;
165 update_user_core(req, &raw_body, user, session, context).await
166}
167
168#[expect(
169 clippy::too_many_lines,
170 reason = "Keep field validation and callbacks adjacent to their profile persistence owner"
171)]
172async fn update_user_core<S: alibi_core::AuthSchema>(
173 req: &impl SessionRequest,
174 raw_body: &JsValue,
175 current_user: alibi_core::AuthenticatedUser<S>,
176 current_session: alibi_core::SessionView,
177 context: &AuthContext<S>,
178) -> AuthResult<AuthResponse> {
179 let body: serde_json::Value = raw_body.to_json_value()?;
180 let Some(body) = body.as_object() else {
181 let actual = match &body {
182 serde_json::Value::Null => "null",
183 serde_json::Value::Bool(_) => "boolean",
184 serde_json::Value::Number(_) => "number",
185 serde_json::Value::String(_) => "string",
186 serde_json::Value::Array(_) => "array",
187 serde_json::Value::Object(_) => "record",
188 };
189 return Ok(AuthResponse::json(
190 400,
191 &ErrorCodeMessageResponse {
192 code: Some("VALIDATION_ERROR".to_owned()),
193 message: format!("[body] Invalid input: expected record, received {actual}"),
194 },
195 )?);
196 };
197
198 if body.get("email").is_some_and(|value| match value {
199 serde_json::Value::Null => false,
200 serde_json::Value::Bool(value) => *value,
201 serde_json::Value::Number(value) => value.as_f64().is_some_and(|value| value != 0.0),
202 serde_json::Value::String(value) => !value.is_empty(),
203 serde_json::Value::Array(_) | serde_json::Value::Object(_) => true,
204 }) {
205 return Err(AuthError::bad_request("Email can not be updated"));
206 }
207
208 if body.contains_key("role") {
211 return Err(AuthError::Api {
212 status: 400,
213 code: Some("FIELD_NOT_ALLOWED".into()),
214 message: "role is not allowed to be set".into(),
215 });
216 }
217
218 crate::last_login_method::reject_last_login_method_input(
219 context,
220 raw_body.get("lastLoginMethod"),
221 )?;
222
223 let mut writable_body = body.clone();
224 _ = writable_body.remove("email");
225 let update_req: UpdateUserRequest =
226 serde_json::from_value(serde_json::Value::Object(writable_body))
227 .map_err(|e| AuthError::bad_request(format!("Invalid JSON: {e}")))?;
228 let policy = context.extensions.get::<UsernameConfig>();
229 if let Some(policy) = &policy {
230 if let Some(value) = &update_req.username {
231 policy.validate_hook_value(value).await?;
232 let normalized = policy.normalize(value)?;
233 let current_username = current_user.username();
234 if policy.immutable_username
235 && current_username
236 .as_ref()
237 .is_some_and(|value| !value.is_empty() && *value != normalized)
238 {
239 return Err(AuthError::Upstream {
240 status: 400,
241 code: "USERNAME_IS_IMMUTABLE",
242 message: "Username cannot be updated",
243 });
244 }
245 if let Some(existing_user) = context.database.get_user_by_username(&normalized).await?
246 && existing_user.id() != current_user.id()
247 {
248 return Err(AuthError::Upstream {
249 status: 400,
250 code: "USERNAME_IS_ALREADY_TAKEN",
251 message: "Username is already taken. Please try another.",
252 });
253 }
254 }
255 if let Some(value) = &update_req.display_username {
256 policy.validate_display(value).await?;
257 }
258 }
259 let mut input_fields = raw_body
260 .as_object()
261 .ok_or_else(|| AuthError::bad_request("Invalid JSON object"))?
262 .clone();
263 if policy.is_none() {
264 _ = input_fields.shift_remove("username");
265 }
266 if policy
267 .as_ref()
268 .is_none_or(|policy| !policy.include_display_username)
269 {
270 _ = input_fields.shift_remove("displayUsername");
271 }
272 let additional_fields = context
273 .parse_user_fields(&input_fields, false)
274 .map_err(crate::authentication_helpers::field_input_error)?;
275 let username = additional_fields
276 .get("username")
277 .and_then(JsValue::as_str)
278 .map(str::to_owned);
279 let display_username = additional_fields
280 .get("displayUsername")
281 .and_then(JsValue::as_str)
282 .map(str::to_owned);
283
284 let clear_phone = context
285 .get_metadata("phone-number.enabled")
286 .and_then(serde_json::Value::as_bool)
287 == Some(true)
288 && body.get("phoneNumber") == Some(&serde_json::Value::Null);
289 let clear_image = body.get("image") == Some(&serde_json::Value::Null);
290 let has_changes = additional_fields.has_input_fields()
291 || clear_image
292 || clear_phone
293 || update_req.name.is_some()
294 || update_req.image.is_some()
295 || username.is_some()
296 || display_username.is_some()
297 || update_req.metadata.is_some();
298 if !has_changes {
299 return Err(AuthError::bad_request("No fields to update"));
300 }
301
302 let update_user = UpdateUser {
303 additional_fields,
304 provider_image: clear_image.then_some(serde_json::Value::Null),
305 phone_number: clear_phone.then_some(None),
306 name: update_req.name,
307 image: update_req.image,
308 username,
309 display_username,
310 metadata: update_req.metadata,
311 ..Default::default()
312 };
313
314 let publication = match context
315 .database
316 .update_user_record(¤t_user.id(), update_user.clone())
317 .await
318 {
319 Ok(updated_user) => alibi_core::CacheVersionContext::created(
320 updated_user.clone(),
321 current_session.clone(),
322 context.user_view(&updated_user),
323 current_session.clone(),
324 ),
325 Err(AuthError::UserNotFound) => {
326 let mut user = context.user_view(¤t_user);
329 if let Some(name) = update_user.name {
330 user.name = Some(name);
331 }
332 if clear_image {
333 user.image = None;
334 }
335 if let Some(image) = update_user.image {
336 user.image = Some(image);
337 }
338 if let Some(username) = update_user.username {
339 user.username = Some(username);
340 }
341 if let Some(display_username) = update_user.display_username {
342 user.display_username = Some(display_username);
343 }
344 if let Some(metadata) = update_user.metadata {
345 user.metadata = metadata;
346 }
347 for (name, value) in update_user.additional_fields {
348 _ = user.extension_fields.insert(name, value.to_json_value()?);
349 }
350 if let Some(phone_number) = update_user.phone_number {
351 user.phone_number = phone_number;
352 _ = user
353 .extension_fields
354 .insert("phoneNumber".into(), serde_json::Value::Null);
355 }
356 alibi_core::CacheVersionContext::created(
357 user.clone(),
358 current_session.clone(),
359 user,
360 current_session.clone(),
361 )
362 }
363 Err(error) => return Err(error),
364 };
365 alibi_core::session::cookie_cache::runtime::emit_issuance_snapshot(context, publication)
366 .await?;
367
368 let mut response = AuthResponse::json(200, &alibi_core::StatusResponse { status: true })?;
369
370 if let Some(token) = context.session_manager().extract_session_token(req) {
371 let cookie_header =
372 alibi_core::utils::cookie_utils::create_session_cookie(&token, &context.config)?;
373 response = response.with_header("Set-Cookie", cookie_header);
374 }
375
376 Ok(response)
377}