Skip to main content

wekan_cli/client/
users.rs

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}