Skip to main content

wekan_cli/client/
users.rs

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}