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