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