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, 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}