1use std::collections::BTreeMap;
2
3use reqwest::header::ACCEPT;
4use secrecy::{ExposeSecret, SecretString};
5use serde::{Deserialize, Deserializer, Serialize};
6use serde_json::{Number, Value};
7
8use super::{ClientError, WekanClient, boards::BoardSummary, transport::decode_success};
9
10#[derive(Debug)]
11pub struct CreateUserRequest {
12 pub username: String,
13 pub email: String,
14 pub password: SecretString,
15}
16
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18pub enum UserAction {
19 TakeOwnership,
20 DisableLogin,
21 EnableLogin,
22}
23
24#[derive(Debug, Eq, PartialEq)]
25pub enum UserActionResult {
26 OwnershipTransferred(Vec<BoardSummary>),
27 LoginChanged(Box<UserRecord>),
28}
29
30impl UserAction {
31 const fn as_wire_value(self) -> &'static str {
32 match self {
33 Self::TakeOwnership => "takeOwnership",
34 Self::DisableLogin => "disableLogin",
35 Self::EnableLogin => "enableLogin",
36 }
37 }
38}
39
40#[derive(Debug, Default, Eq, PartialEq)]
41pub struct UserCardQuery {
42 pub due: bool,
43 pub from: Option<String>,
44 pub to: Option<String>,
45}
46
47#[derive(Debug, Eq, PartialEq)]
48pub struct CreateUserResult;
49
50#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
51#[serde(deny_unknown_fields)]
52pub struct UserSummary {
53 #[serde(rename = "_id")]
54 pub user_id: String,
55 pub username: Option<String>,
56}
57
58#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
59#[serde(deny_unknown_fields)]
60pub struct UserEmail {
61 pub address: Option<String>,
62 pub verified: Option<bool>,
63}
64
65impl UserEmail {
66 pub fn address(&self) -> Option<&str> {
67 self.address.as_deref()
68 }
69
70 pub const fn verified(&self) -> Option<bool> {
71 self.verified
72 }
73
74 pub fn into_parts(self) -> (Option<String>, Option<bool>) {
75 (self.address, self.verified)
76 }
77}
78
79pub type CurrentUserEmail = UserEmail;
80
81#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
82#[serde(deny_unknown_fields, rename_all = "camelCase")]
83pub struct UserProfile {
84 pub fullname: Option<String>,
85 avatar_url: Option<String>,
86 email_buffer: Option<Vec<Option<String>>>,
87 show_desktop_drag_handles: Option<bool>,
88 left_menu_collapsed: Option<bool>,
89 collapsed_workspaces: Option<BTreeMap<String, bool>>,
90 left_menu_width: Option<Number>,
91 submit_on_enter: Option<bool>,
92 open_many_cards_at_once: Option<bool>,
93 all_boards_theme_tiles: Option<bool>,
94 global_theme_color: Option<String>,
95 global_theme_custom_colors: Option<Vec<Option<String>>>,
96 ui_font: Option<String>,
97 ui_font_size: Option<String>,
98 ui_text_color: Option<String>,
99 ui_text_bg_color: Option<String>,
100 dismissed_announcement_version: Option<String>,
101 card_maximized: Option<bool>,
102 card_collapsed: Option<bool>,
103 show_activities: Option<bool>,
104 custom_fields_grid: Option<bool>,
105 trello_api_saved: Option<bool>,
106 hidden_minicard_label_text: Option<bool>,
107 initials: Option<String>,
108 board_workspaces_tree: Option<Vec<BTreeMap<String, Value>>>,
109 board_workspace_assignments: Option<BTreeMap<String, String>>,
110 invited_boards: Option<Vec<Option<String>>>,
111 language: Option<String>,
112 map_provider: Option<String>,
113 move_and_copy_dialog: Option<BTreeMap<String, Value>>,
114 move_checklist_dialog: Option<BTreeMap<String, Value>>,
115 copy_checklist_dialog: Option<BTreeMap<String, Value>>,
116 notifications: Option<Vec<UserProfileNotification>>,
117 rescue_card_description: Option<bool>,
118 show_cards_count_at: Option<Number>,
119 start_day_of_week: Option<Number>,
120 starred_boards: Option<Vec<Option<String>>>,
121 starred_pages: Option<Vec<UserProfileStarredPage>>,
122 default_board_id: Option<String>,
123 icode: Option<String>,
124 board_view: Option<String>,
125 list_sort_by: Option<String>,
126 all_boards_sort_by: Option<String>,
127 templates_board_id: Option<String>,
128 card_templates_swimlane_id: Option<String>,
129 list_templates_swimlane_id: Option<String>,
130 board_templates_swimlane_id: Option<String>,
131 sidebar_width: Option<Number>,
132 list_widths: Option<BTreeMap<String, BTreeMap<String, Number>>>,
133 list_constraints: Option<BTreeMap<String, BTreeMap<String, Value>>>,
134 auto_width_boards: Option<BTreeMap<String, bool>>,
135 board_sort_index: Option<BTreeMap<String, Number>>,
136 fixed_list_width_boards: Option<BTreeMap<String, bool>>,
137 fixed_list_widths: Option<BTreeMap<String, Number>>,
138 swimlane_heights: Option<BTreeMap<String, BTreeMap<String, Number>>>,
139 collapsed_lists: Option<BTreeMap<String, BTreeMap<String, bool>>>,
140 collapsed_swimlanes: Option<BTreeMap<String, BTreeMap<String, bool>>>,
141 collapsed_card_sections: Option<BTreeMap<String, BTreeMap<String, bool>>>,
142 keyboard_shortcuts: Option<bool>,
143 vertical_scrollbars: Option<bool>,
144 show_week_of_year: Option<bool>,
145 date_format: Option<String>,
146 mobile_mode: Option<bool>,
147 card_zoom: Option<Number>,
148}
149
150#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
151#[serde(deny_unknown_fields)]
152struct UserProfileNotification {
153 activity: String,
154 read: String,
155}
156
157#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
158#[serde(deny_unknown_fields)]
159struct UserProfileStarredPage {
160 url: String,
161 title: String,
162}
163
164pub type CurrentUserProfile = UserProfile;
165
166#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
167#[serde(deny_unknown_fields, rename_all = "camelCase")]
168pub struct UserOrganization {
169 pub org_id: Option<String>,
170 pub org_display_name: Option<String>,
171 pub is_admin: Option<bool>,
172}
173
174#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
175#[serde(deny_unknown_fields, rename_all = "camelCase")]
176pub struct UserTeam {
177 pub team_id: Option<String>,
178 pub team_display_name: Option<String>,
179}
180
181#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
182#[serde(deny_unknown_fields, rename_all = "camelCase")]
183pub struct CurrentUserBoard {
184 pub board_id: String,
185 pub is_active: Option<bool>,
186 pub is_admin: Option<bool>,
187 pub is_no_comments: Option<bool>,
188 pub is_comment_only: Option<bool>,
189 pub is_worker: Option<bool>,
190 pub is_normal_assigned_only: Option<bool>,
191 pub is_comment_assigned_only: Option<bool>,
192 pub is_read_only: Option<bool>,
193 pub is_read_assigned_only: Option<bool>,
194}
195
196#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
197#[serde(deny_unknown_fields, rename_all = "camelCase")]
198pub struct UserRecord {
199 #[serde(rename = "_id")]
200 pub user_id: String,
201 pub username: Option<String>,
202 #[serde(default, deserialize_with = "deserialize_default_on_null")]
203 pub emails: Vec<UserEmail>,
204 #[serde(default, deserialize_with = "deserialize_default_on_null")]
205 pub profile: UserProfile,
206 pub is_admin: Option<bool>,
207 pub login_disabled: Option<bool>,
208 pub authentication_method: Option<String>,
209 pub created_at: Option<String>,
210 pub modified_at: Option<String>,
211 pub created_through_api: Option<bool>,
212 pub heartbeat: Option<String>,
213 pub last_connection_date: Option<String>,
214 pub session_data: Option<UserSessionData>,
215 #[serde(default, deserialize_with = "deserialize_default_on_null")]
216 pub import_usernames: Vec<String>,
217 #[serde(default, deserialize_with = "deserialize_default_on_null")]
218 pub orgs: Vec<UserOrganization>,
219 #[serde(default, deserialize_with = "deserialize_default_on_null")]
220 pub teams: Vec<UserTeam>,
221 #[serde(default, deserialize_with = "deserialize_default_on_null")]
222 pub boards: Vec<CurrentUserBoard>,
223}
224
225#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
226#[serde(deny_unknown_fields, rename_all = "camelCase")]
227pub struct UserSessionData {
228 pub total_hits: Option<Number>,
229}
230
231impl UserRecord {
232 pub fn user_id(&self) -> &str {
233 &self.user_id
234 }
235
236 pub fn username(&self) -> Option<&str> {
237 self.username.as_deref()
238 }
239
240 pub fn full_name(&self) -> Option<&str> {
241 self.profile.fullname.as_deref()
242 }
243
244 pub const fn is_admin(&self) -> Option<bool> {
245 self.is_admin
246 }
247
248 pub fn emails(&self) -> &[UserEmail] {
249 &self.emails
250 }
251
252 pub fn into_auth_parts(
253 self,
254 ) -> (
255 String,
256 Option<String>,
257 Option<String>,
258 Option<bool>,
259 Vec<UserEmail>,
260 ) {
261 (
262 self.user_id,
263 self.username,
264 self.profile.fullname,
265 self.is_admin,
266 self.emails,
267 )
268 }
269}
270
271pub type CurrentUser = UserRecord;
272
273#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
274#[serde(deny_unknown_fields, rename_all = "camelCase")]
275pub struct UserCard {
276 #[serde(rename = "_id")]
277 pub card_id: String,
278 pub title: Option<String>,
279 pub board_id: Option<String>,
280 pub swimlane_id: Option<String>,
281 pub list_id: Option<String>,
282 pub due_at: Option<String>,
283 pub start_at: Option<String>,
284 pub end_at: Option<String>,
285 #[serde(default, deserialize_with = "deserialize_default_on_null")]
286 pub members: Vec<String>,
287 #[serde(default, deserialize_with = "deserialize_default_on_null")]
288 pub assignees: Vec<String>,
289}
290
291fn deserialize_default_on_null<'de, D, T>(deserializer: D) -> Result<T, D::Error>
292where
293 D: Deserializer<'de>,
294 T: Deserialize<'de> + Default,
295{
296 Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
297}
298
299#[derive(Serialize)]
300struct CreateUserRequestBody<'a> {
301 username: &'a str,
302 email: &'a str,
303 password: &'a str,
304}
305
306#[derive(Serialize)]
307struct UserActionRequestBody {
308 action: &'static str,
309}
310
311#[derive(Deserialize)]
312#[serde(deny_unknown_fields)]
313struct CreateUserV1106Response {
314 #[serde(rename = "_id")]
315 _id: EmptyObject,
316}
317
318#[derive(Deserialize)]
319#[serde(deny_unknown_fields)]
320struct EmptyObject {}
321
322#[derive(Deserialize)]
323#[serde(deny_unknown_fields)]
324struct IdResponse {
325 #[serde(rename = "_id")]
326 id: String,
327}
328
329impl WekanClient {
330 pub async fn current_user(&self, token: &SecretString) -> Result<CurrentUser, ClientError> {
331 let body = self
332 .execute_success_request(
333 self.http
334 .get(self.endpoint("api/user", "current-user")?)
335 .header(ACCEPT, "application/json")
336 .bearer_auth(token.expose_secret()),
337 )
338 .await?;
339 let user = decode_success(&body, "current-user", true)?;
340 validate_user_record(user, "current-user")
341 }
342
343 pub async fn user_cards(
344 &self,
345 query: &UserCardQuery,
346 token: &SecretString,
347 ) -> Result<Vec<UserCard>, ClientError> {
348 let mut endpoint = self.endpoint("api/user/cards", "current-user cards")?;
349 {
350 let mut pairs = endpoint.query_pairs_mut();
351 if query.due {
352 pairs.append_pair("due", "true");
353 }
354 if let Some(from) = &query.from {
355 pairs.append_pair("from", from);
356 }
357 if let Some(to) = &query.to {
358 pairs.append_pair("to", to);
359 }
360 }
361 let body = self
362 .execute_success_request(
363 self.http
364 .get(endpoint)
365 .header(ACCEPT, "application/json")
366 .bearer_auth(token.expose_secret()),
367 )
368 .await?;
369 decode_success(&body, "current-user cards", true)
370 }
371
372 pub async fn users(&self, token: &SecretString) -> Result<Vec<UserSummary>, ClientError> {
373 let body = self
374 .execute_success_request(
375 self.http
376 .get(self.endpoint("api/users", "users")?)
377 .header(ACCEPT, "application/json")
378 .bearer_auth(token.expose_secret()),
379 )
380 .await?;
381 decode_success(&body, "users", true)
382 }
383
384 pub async fn user(
385 &self,
386 selector: &str,
387 token: &SecretString,
388 ) -> Result<UserRecord, ClientError> {
389 let endpoint = self.user_endpoint(selector, None)?;
390 let body = self
391 .execute_success_request(
392 self.http
393 .get(endpoint)
394 .header(ACCEPT, "application/json")
395 .bearer_auth(token.expose_secret()),
396 )
397 .await?;
398 let user = decode_success(&body, "user", true)?;
399 validate_user_record(user, "user")
400 }
401
402 pub async fn create_user(
403 &self,
404 request: &CreateUserRequest,
405 token: &SecretString,
406 ) -> Result<CreateUserResult, ClientError> {
407 let body = CreateUserRequestBody {
408 username: &request.username,
409 email: &request.email,
410 password: request.password.expose_secret(),
411 };
412 let response = self
413 .execute_success_request(
414 self.http
415 .post(self.endpoint("api/users", "create-user")?)
416 .header(ACCEPT, "application/json")
417 .bearer_auth(token.expose_secret())
418 .json(&body),
419 )
420 .await?;
421 let _: CreateUserV1106Response = decode_success(&response, "create-user", true)?;
422 Ok(CreateUserResult)
423 }
424
425 pub async fn user_boards(
426 &self,
427 user_id: &str,
428 token: &SecretString,
429 ) -> Result<Vec<BoardSummary>, ClientError> {
430 let endpoint = self.user_endpoint(user_id, Some("boards"))?;
431 let body = self
432 .execute_success_request(
433 self.http
434 .get(endpoint)
435 .header(ACCEPT, "application/json")
436 .bearer_auth(token.expose_secret()),
437 )
438 .await?;
439 decode_success(&body, "user boards", true)
440 }
441
442 pub async fn user_action(
443 &self,
444 user_id: &str,
445 action: UserAction,
446 token: &SecretString,
447 ) -> Result<UserActionResult, ClientError> {
448 let endpoint = self.user_endpoint(user_id, None)?;
449 let body = self
450 .execute_success_request(
451 self.http
452 .put(endpoint)
453 .header(ACCEPT, "application/json")
454 .bearer_auth(token.expose_secret())
455 .json(&UserActionRequestBody {
456 action: action.as_wire_value(),
457 }),
458 )
459 .await?;
460 match action {
461 UserAction::TakeOwnership => Ok(UserActionResult::OwnershipTransferred(
462 decode_success(&body, "take ownership", true)?,
463 )),
464 UserAction::DisableLogin | UserAction::EnableLogin => {
465 let user = decode_success(&body, "login action", true)?;
466 Ok(UserActionResult::LoginChanged(Box::new(
467 validate_user_record(user, "login action")?,
468 )))
469 }
470 }
471 }
472
473 pub async fn delete_user(
474 &self,
475 user_id: &str,
476 token: &SecretString,
477 ) -> Result<String, ClientError> {
478 let endpoint = self.user_endpoint(user_id, None)?;
479 let body = self
480 .execute_success_request(
481 self.http
482 .delete(endpoint)
483 .header(ACCEPT, "application/json")
484 .bearer_auth(token.expose_secret()),
485 )
486 .await?;
487 let response: IdResponse = decode_success(&body, "delete-user", true)?;
488 if response.id.is_empty() {
489 return Err(ClientError::Protocol {
490 message: "the delete-user response contained an empty id".to_owned(),
491 success_status_received: true,
492 });
493 }
494 Ok(response.id)
495 }
496
497 fn endpoint(&self, path: &str, operation: &str) -> Result<reqwest::Url, ClientError> {
498 self.server()
499 .join(path)
500 .map_err(|error| ClientError::Protocol {
501 message: format!("could not build the {operation} endpoint: {error}"),
502 success_status_received: false,
503 })
504 }
505
506 fn user_endpoint(
507 &self,
508 selector: &str,
509 suffix: Option<&str>,
510 ) -> Result<reqwest::Url, ClientError> {
511 let mut endpoint = self.endpoint("api/users", "user")?;
512 endpoint
513 .path_segments_mut()
514 .map_err(|()| ClientError::Protocol {
515 message: "could not add the user selector to the endpoint".to_owned(),
516 success_status_received: false,
517 })?
518 .push(selector);
519 if let Some(suffix) = suffix {
520 endpoint
521 .path_segments_mut()
522 .expect("an HTTP endpoint with a host supports path segments")
523 .push(suffix);
524 }
525 Ok(endpoint)
526 }
527}
528
529fn validate_user_record(user: UserRecord, operation: &str) -> Result<UserRecord, ClientError> {
530 if user.user_id.is_empty() {
531 Err(ClientError::Protocol {
532 message: format!("the {operation} response contained an empty user id"),
533 success_status_received: true,
534 })
535 } else {
536 Ok(user)
537 }
538}
539
540#[cfg(test)]
541mod strict_response_tests {
542 use serde_json::json;
543
544 use super::{IdResponse, UserCard, UserRecord, UserSummary};
545
546 #[test]
547 fn user_responses_accept_mapped_dynamic_properties() {
548 let user = serde_json::from_value::<UserRecord>(json!({
549 "_id": "user-1",
550 "profile": {
551 "fullname": "Alice",
552 "collapsedWorkspaces": {"workspace-1": true},
553 "boardWorkspaceAssignments": {"board-1": "workspace-1"},
554 "moveAndCopyDialog": {"boardId": "board-1"},
555 "listWidths": {"board-1": {"list-1": 320}},
556 "listConstraints": {"board-1": {"list-1": "unlimited"}}
557 }
558 }))
559 .unwrap();
560
561 assert_eq!(user.user_id(), "user-1");
562 assert_eq!(user.full_name(), Some("Alice"));
563 }
564
565 #[test]
566 fn user_responses_reject_unmapped_top_level_and_nested_fields() {
567 assert!(
568 serde_json::from_value::<UserRecord>(json!({
569 "_id": "user-1",
570 "unexpected": true
571 }))
572 .is_err()
573 );
574 assert!(
575 serde_json::from_value::<UserRecord>(json!({
576 "_id": "user-1",
577 "profile": {"unexpected": true}
578 }))
579 .is_err()
580 );
581 assert!(
582 serde_json::from_value::<UserSummary>(json!({
583 "_id": "user-1",
584 "unexpected": true
585 }))
586 .is_err()
587 );
588 assert!(
589 serde_json::from_value::<UserCard>(json!({
590 "_id": "card-1",
591 "unexpected": true
592 }))
593 .is_err()
594 );
595 assert!(
596 serde_json::from_value::<IdResponse>(json!({
597 "_id": "user-1",
598 "unexpected": true
599 }))
600 .is_err()
601 );
602 }
603}