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: SecretString,
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    avatar_url: Option<String>,
86    email_buffer: Option<Vec<Option<String>>>,
87    show_desktop_drag_handles: Option<bool>,
88    left_menu_collapsed: Option<bool>,
89    collapsed_workspaces: Option<BTreeMap<String, bool>>,
90    left_menu_width: Option<Number>,
91    submit_on_enter: Option<bool>,
92    open_many_cards_at_once: Option<bool>,
93    all_boards_theme_tiles: Option<bool>,
94    global_theme_color: Option<String>,
95    global_theme_custom_colors: Option<Vec<Option<String>>>,
96    ui_font: Option<String>,
97    ui_font_size: Option<String>,
98    ui_text_color: Option<String>,
99    ui_text_bg_color: Option<String>,
100    dismissed_announcement_version: Option<String>,
101    card_maximized: Option<bool>,
102    card_collapsed: Option<bool>,
103    show_activities: Option<bool>,
104    custom_fields_grid: Option<bool>,
105    trello_api_saved: Option<bool>,
106    hidden_minicard_label_text: Option<bool>,
107    initials: Option<String>,
108    board_workspaces_tree: Option<Vec<BTreeMap<String, Value>>>,
109    board_workspace_assignments: Option<BTreeMap<String, String>>,
110    invited_boards: Option<Vec<Option<String>>>,
111    language: Option<String>,
112    map_provider: Option<String>,
113    move_and_copy_dialog: Option<BTreeMap<String, Value>>,
114    move_checklist_dialog: Option<BTreeMap<String, Value>>,
115    copy_checklist_dialog: Option<BTreeMap<String, Value>>,
116    notifications: Option<Vec<UserProfileNotification>>,
117    rescue_card_description: Option<bool>,
118    show_cards_count_at: Option<Number>,
119    start_day_of_week: Option<Number>,
120    starred_boards: Option<Vec<Option<String>>>,
121    starred_pages: Option<Vec<UserProfileStarredPage>>,
122    default_board_id: Option<String>,
123    icode: Option<String>,
124    board_view: Option<String>,
125    list_sort_by: Option<String>,
126    all_boards_sort_by: Option<String>,
127    templates_board_id: Option<String>,
128    card_templates_swimlane_id: Option<String>,
129    list_templates_swimlane_id: Option<String>,
130    board_templates_swimlane_id: Option<String>,
131    sidebar_width: Option<Number>,
132    list_widths: Option<BTreeMap<String, BTreeMap<String, Number>>>,
133    list_constraints: Option<BTreeMap<String, BTreeMap<String, Value>>>,
134    auto_width_boards: Option<BTreeMap<String, bool>>,
135    board_sort_index: Option<BTreeMap<String, Number>>,
136    fixed_list_width_boards: Option<BTreeMap<String, bool>>,
137    fixed_list_widths: Option<BTreeMap<String, Number>>,
138    swimlane_heights: Option<BTreeMap<String, BTreeMap<String, Number>>>,
139    collapsed_lists: Option<BTreeMap<String, BTreeMap<String, bool>>>,
140    collapsed_swimlanes: Option<BTreeMap<String, BTreeMap<String, bool>>>,
141    collapsed_card_sections: Option<BTreeMap<String, BTreeMap<String, bool>>>,
142    keyboard_shortcuts: Option<bool>,
143    vertical_scrollbars: Option<bool>,
144    show_week_of_year: Option<bool>,
145    date_format: Option<String>,
146    mobile_mode: Option<bool>,
147    card_zoom: Option<Number>,
148}
149
150#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
151#[serde(deny_unknown_fields)]
152struct UserProfileNotification {
153    activity: String,
154    read: Option<String>,
155}
156
157#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
158#[serde(deny_unknown_fields)]
159struct UserProfileStarredPage {
160    url: String,
161    title: String,
162}
163
164pub type CurrentUserProfile = UserProfile;
165
166#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
167#[serde(deny_unknown_fields, rename_all = "camelCase")]
168pub struct UserOrganization {
169    pub org_id: Option<String>,
170    pub org_display_name: Option<String>,
171    pub is_admin: Option<bool>,
172}
173
174#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
175#[serde(deny_unknown_fields, rename_all = "camelCase")]
176pub struct UserTeam {
177    pub team_id: Option<String>,
178    pub team_display_name: Option<String>,
179}
180
181#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
182#[serde(deny_unknown_fields, rename_all = "camelCase")]
183pub struct CurrentUserBoard {
184    pub board_id: String,
185    pub is_active: Option<bool>,
186    pub is_admin: Option<bool>,
187    pub is_no_comments: Option<bool>,
188    pub is_comment_only: Option<bool>,
189    pub is_worker: Option<bool>,
190    pub is_normal_assigned_only: Option<bool>,
191    pub is_comment_assigned_only: Option<bool>,
192    pub is_read_only: Option<bool>,
193    pub is_read_assigned_only: Option<bool>,
194}
195
196#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
197#[serde(deny_unknown_fields, rename_all = "camelCase")]
198pub struct UserRecord {
199    #[serde(rename = "_id")]
200    pub user_id: String,
201    pub username: Option<String>,
202    #[serde(default, deserialize_with = "deserialize_default_on_null")]
203    pub emails: Vec<UserEmail>,
204    #[serde(default, deserialize_with = "deserialize_default_on_null")]
205    pub profile: UserProfile,
206    pub is_admin: Option<bool>,
207    pub login_disabled: Option<bool>,
208    pub authentication_method: Option<String>,
209    pub created_at: Option<String>,
210    pub modified_at: Option<String>,
211    pub created_through_api: Option<bool>,
212    pub heartbeat: Option<String>,
213    pub last_connection_date: Option<String>,
214    pub session_data: Option<UserSessionData>,
215    #[serde(default, deserialize_with = "deserialize_default_on_null")]
216    pub import_usernames: Vec<String>,
217    #[serde(default, deserialize_with = "deserialize_default_on_null")]
218    pub orgs: Vec<UserOrganization>,
219    #[serde(default, deserialize_with = "deserialize_default_on_null")]
220    pub teams: Vec<UserTeam>,
221    #[serde(default, deserialize_with = "deserialize_default_on_null")]
222    pub boards: Vec<CurrentUserBoard>,
223}
224
225#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
226#[serde(deny_unknown_fields, rename_all = "camelCase")]
227pub struct UserSessionData {
228    pub total_hits: Option<Number>,
229}
230
231impl UserRecord {
232    pub fn user_id(&self) -> &str {
233        &self.user_id
234    }
235
236    pub fn username(&self) -> Option<&str> {
237        self.username.as_deref()
238    }
239
240    pub fn full_name(&self) -> Option<&str> {
241        self.profile.fullname.as_deref()
242    }
243
244    pub const fn is_admin(&self) -> Option<bool> {
245        self.is_admin
246    }
247
248    pub fn emails(&self) -> &[UserEmail] {
249        &self.emails
250    }
251
252    pub fn into_auth_parts(
253        self,
254    ) -> (
255        String,
256        Option<String>,
257        Option<String>,
258        Option<bool>,
259        Vec<UserEmail>,
260    ) {
261        (
262            self.user_id,
263            self.username,
264            self.profile.fullname,
265            self.is_admin,
266            self.emails,
267        )
268    }
269}
270
271pub type CurrentUser = UserRecord;
272
273#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
274#[serde(deny_unknown_fields, rename_all = "camelCase")]
275pub struct UserCard {
276    #[serde(rename = "_id")]
277    pub card_id: String,
278    pub title: Option<String>,
279    pub board_id: Option<String>,
280    pub swimlane_id: Option<String>,
281    pub list_id: Option<String>,
282    pub due_at: Option<String>,
283    pub start_at: Option<String>,
284    pub end_at: Option<String>,
285    #[serde(default, deserialize_with = "deserialize_default_on_null")]
286    pub members: Vec<String>,
287    #[serde(default, deserialize_with = "deserialize_default_on_null")]
288    pub assignees: Vec<String>,
289}
290
291fn deserialize_default_on_null<'de, D, T>(deserializer: D) -> Result<T, D::Error>
292where
293    D: Deserializer<'de>,
294    T: Deserialize<'de> + Default,
295{
296    Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
297}
298
299#[derive(Serialize)]
300struct CreateUserRequestBody<'a> {
301    username: &'a str,
302    email: &'a str,
303    password: &'a str,
304}
305
306#[derive(Serialize)]
307struct UserActionRequestBody {
308    action: &'static str,
309}
310
311#[derive(Deserialize)]
312#[serde(deny_unknown_fields)]
313struct CreateUserV1106Response {
314    #[serde(rename = "_id")]
315    _id: EmptyObject,
316}
317
318#[derive(Deserialize)]
319#[serde(deny_unknown_fields)]
320struct EmptyObject {}
321
322#[derive(Deserialize)]
323#[serde(deny_unknown_fields)]
324struct IdResponse {
325    #[serde(rename = "_id")]
326    id: String,
327}
328
329impl WekanClient {
330    pub async fn current_user(&self, token: &SecretString) -> Result<CurrentUser, ClientError> {
331        let body = self
332            .execute_success_request(
333                self.http
334                    .get(self.endpoint("api/user", "current-user")?)
335                    .header(ACCEPT, "application/json")
336                    .bearer_auth(token.expose_secret()),
337            )
338            .await?;
339        let user = decode_success(&body, "current-user", true)?;
340        validate_user_record(user, "current-user")
341    }
342
343    pub async fn user_cards(
344        &self,
345        query: &UserCardQuery,
346        token: &SecretString,
347    ) -> Result<Vec<UserCard>, ClientError> {
348        let mut endpoint = self.endpoint("api/user/cards", "current-user cards")?;
349        {
350            let mut pairs = endpoint.query_pairs_mut();
351            if query.due {
352                pairs.append_pair("due", "true");
353            }
354            if let Some(from) = &query.from {
355                pairs.append_pair("from", from);
356            }
357            if let Some(to) = &query.to {
358                pairs.append_pair("to", to);
359            }
360        }
361        let body = self
362            .execute_success_request(
363                self.http
364                    .get(endpoint)
365                    .header(ACCEPT, "application/json")
366                    .bearer_auth(token.expose_secret()),
367            )
368            .await?;
369        decode_success(&body, "current-user cards", true)
370    }
371
372    pub async fn users(&self, token: &SecretString) -> Result<Vec<UserSummary>, ClientError> {
373        let body = self
374            .execute_success_request(
375                self.http
376                    .get(self.endpoint("api/users", "users")?)
377                    .header(ACCEPT, "application/json")
378                    .bearer_auth(token.expose_secret()),
379            )
380            .await?;
381        decode_success(&body, "users", true)
382    }
383
384    pub async fn user(
385        &self,
386        selector: &str,
387        token: &SecretString,
388    ) -> Result<UserRecord, ClientError> {
389        let endpoint = self.user_endpoint(selector, None)?;
390        let body = self
391            .execute_success_request(
392                self.http
393                    .get(endpoint)
394                    .header(ACCEPT, "application/json")
395                    .bearer_auth(token.expose_secret()),
396            )
397            .await?;
398        let user = decode_success(&body, "user", true)?;
399        validate_user_record(user, "user")
400    }
401
402    pub async fn create_user(
403        &self,
404        request: &CreateUserRequest,
405        token: &SecretString,
406    ) -> Result<CreateUserResult, ClientError> {
407        let body = CreateUserRequestBody {
408            username: &request.username,
409            email: &request.email,
410            password: request.password.expose_secret(),
411        };
412        let response = self
413            .execute_success_request(
414                self.http
415                    .post(self.endpoint("api/users", "create-user")?)
416                    .header(ACCEPT, "application/json")
417                    .bearer_auth(token.expose_secret())
418                    .json(&body),
419            )
420            .await?;
421        let _: CreateUserV1106Response = decode_success(&response, "create-user", true)?;
422        Ok(CreateUserResult)
423    }
424
425    pub async fn user_boards(
426        &self,
427        user_id: &str,
428        token: &SecretString,
429    ) -> Result<Vec<BoardSummary>, ClientError> {
430        let endpoint = self.user_endpoint(user_id, Some("boards"))?;
431        let body = self
432            .execute_success_request(
433                self.http
434                    .get(endpoint)
435                    .header(ACCEPT, "application/json")
436                    .bearer_auth(token.expose_secret()),
437            )
438            .await?;
439        decode_success(&body, "user boards", true)
440    }
441
442    pub async fn user_action(
443        &self,
444        user_id: &str,
445        action: UserAction,
446        token: &SecretString,
447    ) -> Result<UserActionResult, ClientError> {
448        let endpoint = self.user_endpoint(user_id, None)?;
449        let body = self
450            .execute_success_request(
451                self.http
452                    .put(endpoint)
453                    .header(ACCEPT, "application/json")
454                    .bearer_auth(token.expose_secret())
455                    .json(&UserActionRequestBody {
456                        action: action.as_wire_value(),
457                    }),
458            )
459            .await?;
460        match action {
461            UserAction::TakeOwnership => Ok(UserActionResult::OwnershipTransferred(
462                decode_success(&body, "take ownership", true)?,
463            )),
464            UserAction::DisableLogin | UserAction::EnableLogin => {
465                let user = decode_success(&body, "login action", true)?;
466                Ok(UserActionResult::LoginChanged(Box::new(
467                    validate_user_record(user, "login action")?,
468                )))
469            }
470        }
471    }
472
473    pub async fn delete_user(
474        &self,
475        user_id: &str,
476        token: &SecretString,
477    ) -> Result<String, ClientError> {
478        let endpoint = self.user_endpoint(user_id, None)?;
479        let body = self
480            .execute_success_request(
481                self.http
482                    .delete(endpoint)
483                    .header(ACCEPT, "application/json")
484                    .bearer_auth(token.expose_secret()),
485            )
486            .await?;
487        let response: IdResponse = decode_success(&body, "delete-user", true)?;
488        if response.id.is_empty() {
489            return Err(ClientError::Protocol {
490                diagnostic: None,
491                message: "the delete-user response contained an empty id".to_owned(),
492                success_status_received: true,
493            });
494        }
495        Ok(response.id)
496    }
497
498    fn endpoint(&self, path: &str, operation: &str) -> Result<reqwest::Url, ClientError> {
499        self.server()
500            .join(path)
501            .map_err(|error| ClientError::Protocol {
502                diagnostic: None,
503                message: format!("could not build the {operation} endpoint: {error}"),
504                success_status_received: false,
505            })
506    }
507
508    fn user_endpoint(
509        &self,
510        selector: &str,
511        suffix: Option<&str>,
512    ) -> Result<reqwest::Url, ClientError> {
513        let mut endpoint = self.endpoint("api/users", "user")?;
514        endpoint
515            .path_segments_mut()
516            .map_err(|()| ClientError::Protocol {
517                diagnostic: None,
518                message: "could not add the user selector to the endpoint".to_owned(),
519                success_status_received: false,
520            })?
521            .push(selector);
522        if let Some(suffix) = suffix {
523            endpoint
524                .path_segments_mut()
525                .expect("an HTTP endpoint with a host supports path segments")
526                .push(suffix);
527        }
528        Ok(endpoint)
529    }
530}
531
532fn validate_user_record(user: UserRecord, operation: &str) -> Result<UserRecord, ClientError> {
533    if user.user_id.is_empty() {
534        Err(ClientError::Protocol {
535            diagnostic: None,
536            message: format!("the {operation} response contained an empty user id"),
537            success_status_received: true,
538        })
539    } else {
540        Ok(user)
541    }
542}
543
544#[cfg(test)]
545mod strict_response_tests {
546    use serde_json::json;
547
548    use super::{IdResponse, UserCard, UserRecord, UserSummary};
549
550    #[test]
551    fn user_responses_accept_mapped_dynamic_properties() {
552        let user = serde_json::from_value::<UserRecord>(json!({
553            "_id": "user-1",
554            "profile": {
555                "fullname": "Alice",
556                "collapsedWorkspaces": {"workspace-1": true},
557                "boardWorkspaceAssignments": {"board-1": "workspace-1"},
558                "moveAndCopyDialog": {"boardId": "board-1"},
559                "listWidths": {"board-1": {"list-1": 320}},
560                "listConstraints": {"board-1": {"list-1": "unlimited"}}
561            }
562        }))
563        .unwrap();
564
565        assert_eq!(user.user_id(), "user-1");
566        assert_eq!(user.full_name(), Some("Alice"));
567    }
568
569    #[test]
570    fn user_responses_reject_unmapped_top_level_and_nested_fields() {
571        assert!(
572            serde_json::from_value::<UserRecord>(json!({
573                "_id": "user-1",
574                "unexpected": true
575            }))
576            .is_err()
577        );
578        assert!(
579            serde_json::from_value::<UserRecord>(json!({
580                "_id": "user-1",
581                "profile": {"unexpected": true}
582            }))
583            .is_err()
584        );
585        assert!(
586            serde_json::from_value::<UserSummary>(json!({
587                "_id": "user-1",
588                "unexpected": true
589            }))
590            .is_err()
591        );
592        assert!(
593            serde_json::from_value::<UserCard>(json!({
594                "_id": "card-1",
595                "unexpected": true
596            }))
597            .is_err()
598        );
599        assert!(
600            serde_json::from_value::<IdResponse>(json!({
601                "_id": "user-1",
602                "unexpected": true
603            }))
604            .is_err()
605        );
606    }
607}