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: UserPassword,
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 pub avatar_url: Option<String>,
86 pub email_buffer: Option<Vec<Option<String>>>,
87 pub show_desktop_drag_handles: Option<bool>,
88 pub left_menu_collapsed: Option<bool>,
89 pub collapsed_workspaces: Option<BTreeMap<String, bool>>,
90 pub left_menu_width: Option<Number>,
91 pub submit_on_enter: Option<bool>,
92 pub open_many_cards_at_once: Option<bool>,
93 pub all_boards_theme_tiles: Option<bool>,
94 pub global_theme_color: Option<String>,
95 pub global_theme_custom_colors: Option<Vec<Option<String>>>,
96 pub ui_font: Option<String>,
97 pub ui_font_size: Option<String>,
98 pub ui_text_color: Option<String>,
99 pub ui_text_bg_color: Option<String>,
100 pub dismissed_announcement_version: Option<String>,
101 pub card_maximized: Option<bool>,
102 pub card_collapsed: Option<bool>,
103 pub show_activities: Option<bool>,
104 pub custom_fields_grid: Option<bool>,
105 pub trello_api_saved: Option<bool>,
106 pub hidden_minicard_label_text: Option<bool>,
107 pub initials: Option<String>,
108 pub board_workspaces_tree: Option<Vec<UserWorkspaceNode>>,
109 pub board_workspace_assignments: Option<BTreeMap<String, String>>,
110 pub invited_boards: Option<Vec<Option<String>>>,
111 pub language: Option<String>,
112 pub map_provider: Option<String>,
113 pub move_and_copy_dialog: Option<BTreeMap<String, Value>>,
114 pub move_checklist_dialog: Option<BTreeMap<String, Value>>,
115 pub copy_checklist_dialog: Option<BTreeMap<String, Value>>,
116 pub notifications: Option<Vec<UserProfileNotification>>,
117 pub rescue_card_description: Option<bool>,
118 pub show_cards_count_at: Option<Number>,
119 pub start_day_of_week: Option<Number>,
120 pub starred_boards: Option<Vec<Option<String>>>,
121 pub starred_pages: Option<Vec<UserProfileStarredPage>>,
122 pub default_board_id: Option<String>,
123 pub icode: Option<String>,
124 pub board_view: Option<String>,
125 pub list_sort_by: Option<String>,
126 pub all_boards_sort_by: Option<String>,
127 pub templates_board_id: Option<String>,
128 pub card_templates_swimlane_id: Option<String>,
129 pub list_templates_swimlane_id: Option<String>,
130 pub board_templates_swimlane_id: Option<String>,
131 pub sidebar_width: Option<Number>,
132 pub list_widths: Option<BTreeMap<String, BTreeMap<String, Number>>>,
133 pub list_constraints: Option<BTreeMap<String, BTreeMap<String, Value>>>,
134 pub auto_width_boards: Option<BTreeMap<String, bool>>,
135 pub board_sort_index: Option<BTreeMap<String, Number>>,
136 pub fixed_list_width_boards: Option<BTreeMap<String, bool>>,
137 pub fixed_list_widths: Option<BTreeMap<String, Number>>,
138 pub swimlane_heights: Option<BTreeMap<String, BTreeMap<String, Number>>>,
139 pub collapsed_lists: Option<BTreeMap<String, BTreeMap<String, bool>>>,
140 pub collapsed_swimlanes: Option<BTreeMap<String, BTreeMap<String, bool>>>,
141 pub collapsed_card_sections: Option<BTreeMap<String, BTreeMap<String, bool>>>,
142 pub keyboard_shortcuts: Option<bool>,
143 pub vertical_scrollbars: Option<bool>,
144 pub show_week_of_year: Option<bool>,
145 pub date_format: Option<String>,
146 pub mobile_mode: Option<bool>,
147 pub card_zoom: Option<Number>,
148}
149
150#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
151#[serde(deny_unknown_fields)]
152pub struct UserWorkspaceNode {
153 pub id: Option<String>,
154 pub name: Option<String>,
155 pub icon: Option<String>,
156 pub children: Option<Vec<UserWorkspaceNode>>,
157}
158
159#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
160#[serde(deny_unknown_fields)]
161pub struct UserProfileNotification {
162 pub activity: String,
163 pub read: Option<String>,
164}
165
166#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
167#[serde(deny_unknown_fields)]
168pub struct UserProfileStarredPage {
169 pub url: String,
170 pub title: String,
171}
172
173pub type CurrentUserProfile = UserProfile;
174
175#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
176#[serde(deny_unknown_fields, rename_all = "camelCase")]
177pub struct UserOrganization {
178 pub org_id: Option<String>,
179 pub org_display_name: Option<String>,
180 pub is_admin: Option<bool>,
181}
182
183#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
184#[serde(deny_unknown_fields, rename_all = "camelCase")]
185pub struct UserTeam {
186 pub team_id: Option<String>,
187 pub team_display_name: Option<String>,
188}
189
190#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
191#[serde(deny_unknown_fields, rename_all = "camelCase")]
192pub struct CurrentUserBoard {
193 pub board_id: String,
194 pub is_active: Option<bool>,
195 pub is_admin: Option<bool>,
196 pub is_no_comments: Option<bool>,
197 pub is_comment_only: Option<bool>,
198 pub is_worker: Option<bool>,
199 pub is_normal_assigned_only: Option<bool>,
200 pub is_comment_assigned_only: Option<bool>,
201 pub is_read_only: Option<bool>,
202 pub is_read_assigned_only: Option<bool>,
203}
204
205#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
206#[serde(deny_unknown_fields, rename_all = "camelCase")]
207pub struct UserRecord {
208 #[serde(rename = "_id")]
209 pub user_id: String,
210 pub username: Option<String>,
211 #[serde(default, deserialize_with = "deserialize_default_on_null")]
212 pub emails: Vec<UserEmail>,
213 #[serde(default, deserialize_with = "deserialize_default_on_null")]
214 pub profile: UserProfile,
215 pub is_admin: Option<bool>,
216 pub login_disabled: Option<bool>,
217 pub authentication_method: Option<String>,
218 pub created_at: Option<String>,
219 pub modified_at: Option<String>,
220 pub created_through_api: Option<bool>,
221 pub heartbeat: Option<String>,
222 pub last_connection_date: Option<String>,
223 pub session_data: Option<UserSessionData>,
224 #[serde(default, deserialize_with = "deserialize_default_on_null")]
225 pub import_usernames: Vec<String>,
226 #[serde(default, deserialize_with = "deserialize_default_on_null")]
227 pub orgs: Vec<UserOrganization>,
228 #[serde(default, deserialize_with = "deserialize_default_on_null")]
229 pub teams: Vec<UserTeam>,
230 #[serde(default, deserialize_with = "deserialize_default_on_null")]
231 pub boards: Vec<CurrentUserBoard>,
232}
233
234#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
235#[serde(deny_unknown_fields, rename_all = "camelCase")]
236pub struct UserSessionData {
237 pub total_hits: Option<Number>,
238}
239
240impl UserRecord {
241 pub fn user_id(&self) -> &str {
242 &self.user_id
243 }
244
245 pub fn username(&self) -> Option<&str> {
246 self.username.as_deref()
247 }
248
249 pub fn full_name(&self) -> Option<&str> {
250 self.profile.fullname.as_deref()
251 }
252
253 pub const fn is_admin(&self) -> Option<bool> {
254 self.is_admin
255 }
256
257 pub fn emails(&self) -> &[UserEmail] {
258 &self.emails
259 }
260
261 pub fn into_auth_parts(
262 self,
263 ) -> (
264 String,
265 Option<String>,
266 Option<String>,
267 Option<bool>,
268 Vec<UserEmail>,
269 ) {
270 (
271 self.user_id,
272 self.username,
273 self.profile.fullname,
274 self.is_admin,
275 self.emails,
276 )
277 }
278}
279
280pub type CurrentUser = UserRecord;
281
282#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
283#[serde(deny_unknown_fields, rename_all = "camelCase")]
284pub struct UserCard {
285 #[serde(rename = "_id")]
286 pub card_id: String,
287 pub title: Option<String>,
288 pub board_id: Option<String>,
289 pub swimlane_id: Option<String>,
290 pub list_id: Option<String>,
291 pub due_at: Option<String>,
292 pub start_at: Option<String>,
293 pub end_at: Option<String>,
294 #[serde(default, deserialize_with = "deserialize_default_on_null")]
295 pub members: Vec<String>,
296 #[serde(default, deserialize_with = "deserialize_default_on_null")]
297 pub assignees: Vec<String>,
298}
299
300fn deserialize_default_on_null<'de, D, T>(deserializer: D) -> Result<T, D::Error>
301where
302 D: Deserializer<'de>,
303 T: Deserialize<'de> + Default,
304{
305 Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
306}
307
308#[derive(Serialize)]
309struct CreateUserRequestBody<'a> {
310 username: &'a str,
311 email: &'a str,
312 password: &'a UserPassword,
313}
314
315#[derive(Serialize)]
316struct UserActionRequestBody {
317 action: &'static str,
318}
319
320#[derive(Deserialize)]
321#[serde(deny_unknown_fields)]
322struct CreateUserV1106Response {
323 #[serde(rename = "_id")]
324 _id: EmptyObject,
325}
326
327#[derive(Deserialize)]
328#[serde(deny_unknown_fields)]
329struct EmptyObject {}
330
331#[derive(Deserialize)]
332#[serde(deny_unknown_fields)]
333struct IdResponse {
334 #[serde(rename = "_id")]
335 id: String,
336}
337
338impl WekanClient {
339 pub async fn update_user_without_action(
340 &self,
341 user_id: &str,
342 token: &SecretString,
343 ) -> Result<UserRecord, ClientError> {
344 let body = self
345 .execute_success_request(
346 self.http
347 .put(self.user_endpoint(user_id, None)?)
348 .header(ACCEPT, "application/json")
349 .bearer_auth(token.expose_secret())
350 .json(&serde_json::json!({})),
351 )
352 .await?;
353 validate_user_record(decode_success(&body, "user update", true)?, "user update")
354 }
355 pub async fn current_user(&self, token: &SecretString) -> Result<CurrentUser, ClientError> {
356 let body = self
357 .execute_success_request(
358 self.http
359 .get(self.endpoint("api/user", "current-user")?)
360 .header(ACCEPT, "application/json")
361 .bearer_auth(token.expose_secret()),
362 )
363 .await?;
364 let user = decode_success(&body, "current-user", true)?;
365 validate_user_record(user, "current-user")
366 }
367
368 pub async fn user_cards(
369 &self,
370 query: &UserCardQuery,
371 token: &SecretString,
372 ) -> Result<Vec<UserCard>, ClientError> {
373 let mut endpoint = self.endpoint("api/user/cards", "current-user cards")?;
374 {
375 let mut pairs = endpoint.query_pairs_mut();
376 if query.due {
377 pairs.append_pair("due", "true");
378 }
379 if let Some(from) = &query.from {
380 pairs.append_pair("from", from);
381 }
382 if let Some(to) = &query.to {
383 pairs.append_pair("to", to);
384 }
385 }
386 let body = self
387 .execute_success_request(
388 self.http
389 .get(endpoint)
390 .header(ACCEPT, "application/json")
391 .bearer_auth(token.expose_secret()),
392 )
393 .await?;
394 decode_success(&body, "current-user cards", true)
395 }
396
397 pub async fn users(&self, token: &SecretString) -> Result<Vec<UserSummary>, ClientError> {
398 let body = self
399 .execute_success_request(
400 self.http
401 .get(self.endpoint("api/users", "users")?)
402 .header(ACCEPT, "application/json")
403 .bearer_auth(token.expose_secret()),
404 )
405 .await?;
406 decode_success(&body, "users", true)
407 }
408
409 pub async fn user(
410 &self,
411 selector: &str,
412 token: &SecretString,
413 ) -> Result<UserRecord, ClientError> {
414 let endpoint = self.user_endpoint(selector, None)?;
415 let body = self
416 .execute_success_request(
417 self.http
418 .get(endpoint)
419 .header(ACCEPT, "application/json")
420 .bearer_auth(token.expose_secret()),
421 )
422 .await?;
423 let user = decode_success(&body, "user", true)?;
424 validate_user_record(user, "user")
425 }
426
427 pub async fn create_user(
428 &self,
429 request: &CreateUserRequest,
430 token: &SecretString,
431 ) -> Result<CreateUserResult, ClientError> {
432 let body = CreateUserRequestBody {
433 username: &request.username,
434 email: &request.email,
435 password: &request.password,
436 };
437 let response = self
438 .execute_success_request(
439 self.http
440 .post(self.endpoint("api/users", "create-user")?)
441 .header(ACCEPT, "application/json")
442 .bearer_auth(token.expose_secret())
443 .json(&body),
444 )
445 .await?;
446 let _: CreateUserV1106Response = decode_success(&response, "create-user", true)?;
447 Ok(CreateUserResult)
448 }
449
450 pub async fn user_boards(
451 &self,
452 user_id: &str,
453 token: &SecretString,
454 ) -> Result<Vec<BoardSummary>, ClientError> {
455 let endpoint = self.user_endpoint(user_id, Some("boards"))?;
456 let body = self
457 .execute_success_request(
458 self.http
459 .get(endpoint)
460 .header(ACCEPT, "application/json")
461 .bearer_auth(token.expose_secret()),
462 )
463 .await?;
464 decode_success(&body, "user boards", true)
465 }
466
467 pub async fn user_action(
468 &self,
469 user_id: &str,
470 action: UserAction,
471 token: &SecretString,
472 ) -> Result<UserActionResult, ClientError> {
473 let endpoint = self.user_endpoint(user_id, None)?;
474 let body = self
475 .execute_success_request(
476 self.http
477 .put(endpoint)
478 .header(ACCEPT, "application/json")
479 .bearer_auth(token.expose_secret())
480 .json(&UserActionRequestBody {
481 action: action.as_wire_value(),
482 }),
483 )
484 .await?;
485 match action {
486 UserAction::TakeOwnership => Ok(UserActionResult::OwnershipTransferred(
487 decode_success(&body, "take ownership", true)?,
488 )),
489 UserAction::DisableLogin | UserAction::EnableLogin => {
490 let user = decode_success(&body, "login action", true)?;
491 Ok(UserActionResult::LoginChanged(Box::new(
492 validate_user_record(user, "login action")?,
493 )))
494 }
495 }
496 }
497
498 pub async fn delete_user(
499 &self,
500 user_id: &str,
501 token: &SecretString,
502 ) -> Result<String, ClientError> {
503 let endpoint = self.user_endpoint(user_id, None)?;
504 let body = self
505 .execute_success_request(
506 self.http
507 .delete(endpoint)
508 .header(ACCEPT, "application/json")
509 .bearer_auth(token.expose_secret()),
510 )
511 .await?;
512 let response: IdResponse = decode_success(&body, "delete-user", true)?;
513 if response.id.is_empty() {
514 return Err(ClientError::Protocol {
515 diagnostic: None,
516 message: "the delete-user response contained an empty id".to_owned(),
517 success_status_received: true,
518 });
519 }
520 Ok(response.id)
521 }
522
523 fn endpoint(&self, path: &str, operation: &str) -> Result<reqwest::Url, ClientError> {
524 self.server()
525 .join(path)
526 .map_err(|error| ClientError::Protocol {
527 diagnostic: None,
528 message: format!("could not build the {operation} endpoint: {error}"),
529 success_status_received: false,
530 })
531 }
532
533 fn user_endpoint(
534 &self,
535 selector: &str,
536 suffix: Option<&str>,
537 ) -> Result<reqwest::Url, ClientError> {
538 let mut endpoint = self.endpoint("api/users", "user")?;
539 endpoint
540 .path_segments_mut()
541 .map_err(|()| ClientError::Protocol {
542 diagnostic: None,
543 message: "could not add the user selector to the endpoint".to_owned(),
544 success_status_received: false,
545 })?
546 .push(selector);
547 if let Some(suffix) = suffix {
548 endpoint
549 .path_segments_mut()
550 .expect("an HTTP endpoint with a host supports path segments")
551 .push(suffix);
552 }
553 Ok(endpoint)
554 }
555}
556
557fn validate_user_record(user: UserRecord, operation: &str) -> Result<UserRecord, ClientError> {
558 if user.user_id.is_empty() {
559 Err(ClientError::Protocol {
560 diagnostic: None,
561 message: format!("the {operation} response contained an empty user id"),
562 success_status_received: true,
563 })
564 } else {
565 Ok(user)
566 }
567}
568
569#[cfg(test)]
570mod strict_response_tests {
571 use serde_json::json;
572
573 use super::{IdResponse, UserCard, UserRecord, UserSummary};
574
575 #[test]
576 fn user_responses_accept_mapped_dynamic_properties() {
577 let user = serde_json::from_value::<UserRecord>(json!({
578 "_id": "user-1",
579 "profile": {
580 "fullname": "Alice",
581 "collapsedWorkspaces": {"workspace-1": true},
582 "boardWorkspaceAssignments": {"board-1": "workspace-1"},
583 "moveAndCopyDialog": {"boardId": "board-1"},
584 "listWidths": {"board-1": {"list-1": 320}},
585 "listConstraints": {"board-1": {"list-1": "unlimited"}}
586 }
587 }))
588 .unwrap();
589
590 assert_eq!(user.user_id(), "user-1");
591 assert_eq!(user.full_name(), Some("Alice"));
592 }
593
594 #[test]
595 fn user_responses_reject_unmapped_top_level_and_nested_fields() {
596 assert!(
597 serde_json::from_value::<UserRecord>(json!({
598 "_id": "user-1",
599 "unexpected": true
600 }))
601 .is_err()
602 );
603 assert!(
604 serde_json::from_value::<UserRecord>(json!({
605 "_id": "user-1",
606 "profile": {"unexpected": true}
607 }))
608 .is_err()
609 );
610 assert!(
611 serde_json::from_value::<UserSummary>(json!({
612 "_id": "user-1",
613 "unexpected": true
614 }))
615 .is_err()
616 );
617 assert!(
618 serde_json::from_value::<UserCard>(json!({
619 "_id": "card-1",
620 "unexpected": true
621 }))
622 .is_err()
623 );
624 assert!(
625 serde_json::from_value::<IdResponse>(json!({
626 "_id": "user-1",
627 "unexpected": true
628 }))
629 .is_err()
630 );
631 }
632}
633
634#[derive(Clone, Debug)]
635pub enum UserPassword {
636 Plaintext(SecretString),
637 Sha256(SecretString),
638}
639
640#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
641pub enum PasswordDigestError {
642 #[error("invalid password digest object")]
643 InvalidObject,
644 #[error("password digest must use sha-256 and contain 64 UTF-16 code units")]
645 InvalidDigest,
646}
647impl From<SecretString> for UserPassword {
648 fn from(value: SecretString) -> Self {
649 Self::Plaintext(value)
650 }
651}
652impl UserPassword {
653 pub fn secret(&self) -> &SecretString {
654 match self {
655 Self::Plaintext(secret) | Self::Sha256(secret) => secret,
656 }
657 }
658 pub fn from_digest_json(input: &SecretString) -> Result<Self, PasswordDigestError> {
659 #[derive(Deserialize)]
660 #[serde(deny_unknown_fields)]
661 struct Digest {
662 digest: String,
663 algorithm: String,
664 }
665 let value: Digest = serde_json::from_str(input.expose_secret())
666 .map_err(|_| PasswordDigestError::InvalidObject)?;
667 if value.algorithm != "sha-256" || value.digest.encode_utf16().count() != 64 {
668 return Err(PasswordDigestError::InvalidDigest);
669 }
670 Ok(Self::Sha256(SecretString::from(value.digest)))
671 }
672}
673impl Serialize for UserPassword {
674 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
675 match self {
676 Self::Plaintext(value) => serializer.serialize_str(value.expose_secret()),
677 Self::Sha256(value) => {
678 use serde::ser::SerializeStruct;
679 let mut object = serializer.serialize_struct("PasswordDigest", 2)?;
680 object.serialize_field("digest", value.expose_secret())?;
681 object.serialize_field("algorithm", "sha-256")?;
682 object.end()
683 }
684 }
685 }
686}