Skip to main content

wekan_cli/client/
users.rs

1use crate::client::metadata::*;
2use std::collections::BTreeMap;
3
4pub mod channels;
5pub mod tokens;
6
7use reqwest::header::ACCEPT;
8use secrecy::{ExposeSecret, SecretString};
9use serde::{Deserialize, Deserializer, Serialize};
10use serde_json::Number;
11
12use super::{ClientError, WekanClient, boards::BoardSummary, transport::decode_success};
13
14#[derive(Debug)]
15pub struct CreateUserRequest {
16    pub username: String,
17    pub email: String,
18    pub password: UserPassword,
19}
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub enum UserAction {
23    TakeOwnership,
24    DisableLogin,
25    EnableLogin,
26}
27
28#[derive(Debug, Eq, PartialEq)]
29pub enum UserActionResult {
30    OwnershipTransferred(Vec<BoardSummary>),
31    LoginChanged(Box<UserRecord>),
32}
33
34impl UserAction {
35    const fn as_wire_value(self) -> &'static str {
36        match self {
37            Self::TakeOwnership => "takeOwnership",
38            Self::DisableLogin => "disableLogin",
39            Self::EnableLogin => "enableLogin",
40        }
41    }
42}
43
44#[derive(Debug, Default, Eq, PartialEq)]
45pub struct UserCardQuery {
46    pub due: bool,
47    pub from: Option<String>,
48    pub to: Option<String>,
49}
50
51#[derive(Debug, Eq, PartialEq)]
52pub struct CreateUserResult {
53    pub user_id: String,
54}
55
56#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
57#[serde(deny_unknown_fields)]
58pub struct UserSummary {
59    #[serde(rename = "_id")]
60    pub user_id: String,
61    pub username: Option<String>,
62}
63
64#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
65#[serde(deny_unknown_fields)]
66pub struct UserEmail {
67    pub address: Option<String>,
68    pub verified: Option<bool>,
69}
70
71impl UserEmail {
72    pub fn address(&self) -> Option<&str> {
73        self.address.as_deref()
74    }
75
76    pub const fn verified(&self) -> Option<bool> {
77        self.verified
78    }
79
80    pub fn into_parts(self) -> (Option<String>, Option<bool>) {
81        (self.address, self.verified)
82    }
83}
84
85pub type CurrentUserEmail = UserEmail;
86
87#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
88#[serde(deny_unknown_fields, rename_all = "camelCase")]
89pub struct UserProfile {
90    pub chat_view_layout: Option<ChatViewLayout>,
91    pub email_to_card: Option<MemberEmailToCard>,
92    pub notification_delivery: Option<Box<MemberNotificationDelivery>>,
93    pub my_dependencies: Option<Vec<PersonalDependency>>,
94
95    pub notify_muted_activities: Option<Vec<String>>,
96    pub notify_override_tray: Option<bool>,
97    pub notify_override_email: Option<bool>,
98    pub webhook_hide_identity: Option<bool>,
99    pub unread_changes_since: Option<String>,
100    pub keep_title_tags: Option<bool>,
101    pub checklist_ding_sound: Option<bool>,
102    pub dismissed_board_announcements: Option<BTreeMap<String, String>>,
103    pub card_label_text_override: Option<bool>,
104    pub show_label_text_override: Option<bool>,
105    pub board_labels: Option<BTreeMap<String, Vec<String>>>,
106    pub hidden_boards: Option<Vec<String>>,
107    pub starred_swimlanes: Option<Vec<String>>,
108    pub starred_lists: Option<Vec<String>>,
109    pub starred_cards: Option<Vec<String>>,
110    pub default_board_template_id: Option<String>,
111    pub default_board_template_board_id: Option<String>,
112    pub board_views: Option<BTreeMap<String, String>>,
113    pub show_board_dependencies: Option<bool>,
114    pub show_my_dependencies: Option<bool>,
115    pub board_visits: Option<BTreeMap<String, String>>,
116    pub dismissed_rule_suggestions: Option<BTreeMap<String, Vec<String>>>,
117    pub date_format_override: Option<bool>,
118    pub fullname: Option<String>,
119    pub avatar_url: Option<String>,
120    pub email_buffer: Option<Vec<Option<String>>>,
121    pub show_desktop_drag_handles: Option<bool>,
122    pub left_menu_collapsed: Option<bool>,
123    pub collapsed_workspaces: Option<BTreeMap<String, bool>>,
124    pub left_menu_width: Option<Number>,
125    pub submit_on_enter: Option<bool>,
126    pub open_many_cards_at_once: Option<bool>,
127    pub all_boards_theme_tiles: Option<bool>,
128    pub global_theme_color: Option<String>,
129    pub global_theme_custom_colors: Option<Vec<Option<String>>>,
130    pub ui_font: Option<String>,
131    pub ui_font_size: Option<String>,
132    pub ui_text_color: Option<String>,
133    pub ui_text_bg_color: Option<String>,
134    pub dismissed_announcement_version: Option<String>,
135    pub card_maximized: Option<bool>,
136    pub card_collapsed: Option<bool>,
137    pub show_activities: Option<bool>,
138    pub custom_fields_grid: Option<bool>,
139    pub trello_api_saved: Option<bool>,
140    pub hidden_minicard_label_text: Option<bool>,
141    pub initials: Option<String>,
142    pub board_workspaces_tree: Option<Vec<UserWorkspaceNode>>,
143    pub board_workspace_assignments: Option<BTreeMap<String, String>>,
144    pub invited_boards: Option<Vec<Option<String>>>,
145    pub language: Option<String>,
146    pub map_provider: Option<String>,
147    pub move_and_copy_dialog: Option<BTreeMap<String, UserDialogOptions>>,
148    pub move_checklist_dialog: Option<BTreeMap<String, UserDialogOptions>>,
149    pub copy_checklist_dialog: Option<BTreeMap<String, UserDialogOptions>>,
150    pub notifications: Option<Vec<UserProfileNotification>>,
151    pub rescue_card_description: Option<bool>,
152    pub show_cards_count_at: Option<Number>,
153    pub start_day_of_week: Option<Number>,
154    pub starred_boards: Option<Vec<Option<String>>>,
155    pub starred_pages: Option<Vec<UserProfileStarredPage>>,
156    pub default_board_id: Option<String>,
157    pub icode: Option<String>,
158    pub board_view: Option<String>,
159    pub list_sort_by: Option<String>,
160    pub all_boards_sort_by: Option<String>,
161    pub templates_board_id: Option<String>,
162    pub card_templates_swimlane_id: Option<String>,
163    pub list_templates_swimlane_id: Option<String>,
164    pub board_templates_swimlane_id: Option<String>,
165    pub sidebar_width: Option<Number>,
166    pub list_widths: Option<BTreeMap<String, BTreeMap<String, Number>>>,
167    pub list_constraints: Option<BTreeMap<String, BTreeMap<String, Number>>>,
168    pub auto_width_boards: Option<BTreeMap<String, bool>>,
169    pub board_sort_index: Option<BTreeMap<String, Number>>,
170    pub fixed_list_width_boards: Option<BTreeMap<String, bool>>,
171    pub fixed_list_widths: Option<BTreeMap<String, Number>>,
172    pub swimlane_heights: Option<BTreeMap<String, BTreeMap<String, Number>>>,
173    pub collapsed_lists: Option<BTreeMap<String, BTreeMap<String, bool>>>,
174    pub collapsed_swimlanes: Option<BTreeMap<String, BTreeMap<String, bool>>>,
175    pub collapsed_card_sections: Option<BTreeMap<String, BTreeMap<String, bool>>>,
176    pub keyboard_shortcuts: Option<bool>,
177    pub vertical_scrollbars: Option<bool>,
178    pub show_week_of_year: Option<bool>,
179    pub date_format: Option<String>,
180    pub mobile_mode: Option<bool>,
181    pub card_zoom: Option<Number>,
182    pub calendar_system: Option<String>,
183    pub collapsed_cards: Option<BTreeMap<String, BTreeMap<String, bool>>>,
184    pub card_last_views: Option<BTreeMap<String, String>>,
185}
186
187#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
188#[serde(deny_unknown_fields, rename_all = "camelCase")]
189pub struct UserDialogOptions {
190    pub board_id: String,
191    pub swimlane_id: String,
192    pub list_id: String,
193    pub card_id: Option<String>,
194}
195
196#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
197#[serde(deny_unknown_fields)]
198pub struct UserWorkspaceNode {
199    pub id: Option<String>,
200    pub name: Option<String>,
201    pub icon: Option<String>,
202    pub children: Option<Vec<UserWorkspaceNode>>,
203}
204
205#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
206#[serde(deny_unknown_fields)]
207pub struct UserProfileNotification {
208    pub activity: String,
209    pub read: Option<String>,
210    /// Milliseconds since Unix epoch; omitted for immediate delivery without quiet hours.
211    #[serde(
212        rename = "showAt",
213        default,
214        deserialize_with = "super::decoding::optional_non_null"
215    )]
216    pub show_at: Option<Number>,
217}
218
219#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
220#[serde(deny_unknown_fields)]
221pub struct UserProfileStarredPage {
222    pub url: String,
223    pub title: String,
224}
225
226pub type CurrentUserProfile = UserProfile;
227
228#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
229#[serde(deny_unknown_fields, rename_all = "camelCase")]
230pub struct UserOrganization {
231    pub org_id: Option<String>,
232    pub org_display_name: Option<String>,
233    pub is_admin: Option<bool>,
234}
235
236#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
237#[serde(deny_unknown_fields, rename_all = "camelCase")]
238pub struct UserTeam {
239    pub team_id: Option<String>,
240    pub team_display_name: Option<String>,
241}
242
243#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
244#[serde(deny_unknown_fields, rename_all = "camelCase")]
245pub struct CurrentUserBoard {
246    #[serde(default, deserialize_with = "super::decoding::optional_non_null")]
247    pub granted_by: Option<Vec<String>>,
248    #[serde(default, deserialize_with = "super::decoding::optional_non_null")]
249    pub custom_role_id: Option<String>,
250    pub board_id: String,
251    pub is_active: Option<bool>,
252    pub is_admin: Option<bool>,
253    pub is_no_comments: Option<bool>,
254    pub is_comment_only: Option<bool>,
255    pub is_worker: Option<bool>,
256    pub is_normal_assigned_only: Option<bool>,
257    pub is_comment_assigned_only: Option<bool>,
258    pub is_read_only: Option<bool>,
259    pub is_read_assigned_only: Option<bool>,
260}
261
262#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
263#[serde(deny_unknown_fields, rename_all = "camelCase")]
264pub struct UserRecord {
265    pub passkeys: Option<Vec<crate::client::users::channels::Passkey>>,
266    pub sqrl: Option<crate::client::users::channels::SqrlIdentity>,
267    pub web_push_subscriptions: Option<Vec<crate::client::users::channels::PushSubscription>>,
268    pub ntfy: Option<crate::client::users::channels::NtfySettings>,
269    pub notification_delivery_pending: Option<crate::client::users::channels::PendingNotification>,
270
271    pub notification_delivery_revision: Option<String>,
272    pub feature_preview: Option<bool>,
273    pub inactivity_clock_start: Option<String>,
274    pub login_disabled_reason: Option<String>,
275    pub anonymized: Option<bool>,
276    pub anonymized_at: Option<String>,
277
278    #[serde(rename = "_id")]
279    pub user_id: String,
280    pub username: Option<String>,
281    #[serde(default, deserialize_with = "deserialize_default_on_null")]
282    pub emails: Vec<UserEmail>,
283    #[serde(default, deserialize_with = "deserialize_default_on_null")]
284    pub profile: Box<UserProfile>,
285    pub is_admin: Option<bool>,
286    pub login_disabled: Option<bool>,
287    pub authentication_method: Option<String>,
288    pub created_at: Option<String>,
289    pub modified_at: Option<String>,
290    pub created_through_api: Option<bool>,
291    pub heartbeat: Option<String>,
292    pub last_connection_date: Option<String>,
293    pub session_data: Option<UserSessionData>,
294    #[serde(default, deserialize_with = "deserialize_default_on_null")]
295    pub import_usernames: Vec<String>,
296    #[serde(default, deserialize_with = "deserialize_default_on_null")]
297    pub orgs: Vec<UserOrganization>,
298    #[serde(default, deserialize_with = "deserialize_default_on_null")]
299    pub teams: Vec<UserTeam>,
300    #[serde(default, deserialize_with = "deserialize_default_on_null")]
301    pub boards: Vec<CurrentUserBoard>,
302}
303
304#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
305#[serde(deny_unknown_fields, rename_all = "camelCase")]
306pub struct UserSessionData {
307    pub total_hits: Option<Number>,
308}
309
310impl UserRecord {
311    pub fn user_id(&self) -> &str {
312        &self.user_id
313    }
314
315    pub fn username(&self) -> Option<&str> {
316        self.username.as_deref()
317    }
318
319    pub fn full_name(&self) -> Option<&str> {
320        self.profile.fullname.as_deref()
321    }
322
323    pub const fn is_admin(&self) -> Option<bool> {
324        self.is_admin
325    }
326
327    pub fn emails(&self) -> &[UserEmail] {
328        &self.emails
329    }
330
331    pub fn into_auth_parts(
332        self,
333    ) -> (
334        String,
335        Option<String>,
336        Option<String>,
337        Option<bool>,
338        Vec<UserEmail>,
339    ) {
340        (
341            self.user_id,
342            self.username,
343            self.profile.fullname,
344            self.is_admin,
345            self.emails,
346        )
347    }
348}
349
350pub type CurrentUser = UserRecord;
351
352#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
353#[serde(deny_unknown_fields, rename_all = "camelCase")]
354pub struct UserCard {
355    #[serde(rename = "_id")]
356    pub card_id: String,
357    pub title: Option<String>,
358    pub board_id: Option<String>,
359    pub swimlane_id: Option<String>,
360    pub list_id: Option<String>,
361    pub due_at: Option<String>,
362    pub start_at: Option<String>,
363    pub end_at: Option<String>,
364    #[serde(default, deserialize_with = "deserialize_default_on_null")]
365    pub members: Vec<String>,
366    #[serde(default, deserialize_with = "deserialize_default_on_null")]
367    pub assignees: Vec<String>,
368}
369
370fn deserialize_default_on_null<'de, D, T>(deserializer: D) -> Result<T, D::Error>
371where
372    D: Deserializer<'de>,
373    T: Deserialize<'de> + Default,
374{
375    Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
376}
377
378#[derive(Serialize)]
379struct CreateUserRequestBody<'a> {
380    username: &'a str,
381    email: &'a str,
382    password: &'a UserPassword,
383}
384
385#[derive(Serialize)]
386struct UserActionRequestBody {
387    action: &'static str,
388}
389
390#[derive(Deserialize)]
391#[serde(deny_unknown_fields)]
392struct IdResponse {
393    #[serde(rename = "_id")]
394    id: String,
395}
396
397impl WekanClient {
398    pub async fn update_user_without_action(
399        &self,
400        user_id: &str,
401        token: &SecretString,
402    ) -> Result<UserRecord, ClientError> {
403        let body = self
404            .execute_success_request(
405                self.http
406                    .put(self.user_endpoint(user_id, None)?)
407                    .header(ACCEPT, "application/json")
408                    .bearer_auth(token.expose_secret())
409                    .json(&serde_json::json!({})),
410            )
411            .await?;
412        validate_user_record(decode_success(&body, "user update", true)?, "user update")
413    }
414    pub async fn current_user(&self, token: &SecretString) -> Result<CurrentUser, ClientError> {
415        let body = self
416            .execute_success_request(
417                self.http
418                    .get(self.endpoint("api/user", "current-user")?)
419                    .header(ACCEPT, "application/json")
420                    .bearer_auth(token.expose_secret()),
421            )
422            .await?;
423        let user = decode_success(&body, "current-user", true)?;
424        validate_user_record(user, "current-user")
425    }
426
427    pub async fn user_cards(
428        &self,
429        query: &UserCardQuery,
430        token: &SecretString,
431    ) -> Result<Vec<UserCard>, ClientError> {
432        let mut endpoint = self.endpoint("api/user/cards", "current-user cards")?;
433        {
434            let mut pairs = endpoint.query_pairs_mut();
435            if query.due {
436                pairs.append_pair("due", "true");
437            }
438            if let Some(from) = &query.from {
439                pairs.append_pair("from", from);
440            }
441            if let Some(to) = &query.to {
442                pairs.append_pair("to", to);
443            }
444        }
445        let body = self
446            .execute_success_request(
447                self.http
448                    .get(endpoint)
449                    .header(ACCEPT, "application/json")
450                    .bearer_auth(token.expose_secret()),
451            )
452            .await?;
453        decode_success(&body, "current-user cards", true)
454    }
455
456    pub async fn users(&self, token: &SecretString) -> Result<Vec<UserSummary>, ClientError> {
457        let body = self
458            .execute_success_request(
459                self.http
460                    .get(self.endpoint("api/users", "users")?)
461                    .header(ACCEPT, "application/json")
462                    .bearer_auth(token.expose_secret()),
463            )
464            .await?;
465        decode_success(&body, "users", true)
466    }
467
468    pub async fn user(
469        &self,
470        selector: &str,
471        token: &SecretString,
472    ) -> Result<UserRecord, ClientError> {
473        let endpoint = self.user_endpoint(selector, None)?;
474        let body = self
475            .execute_success_request(
476                self.http
477                    .get(endpoint)
478                    .header(ACCEPT, "application/json")
479                    .bearer_auth(token.expose_secret()),
480            )
481            .await?;
482        let user = decode_success(&body, "user", true)?;
483        validate_user_record(user, "user")
484    }
485
486    pub async fn create_user(
487        &self,
488        request: &CreateUserRequest,
489        token: &SecretString,
490    ) -> Result<CreateUserResult, ClientError> {
491        let body = CreateUserRequestBody {
492            username: &request.username,
493            email: &request.email,
494            password: &request.password,
495        };
496        let response = self
497            .execute_success_request(
498                self.http
499                    .post(self.endpoint("api/users", "create-user")?)
500                    .header(ACCEPT, "application/json")
501                    .bearer_auth(token.expose_secret())
502                    .json(&body),
503            )
504            .await?;
505        let response: IdResponse = decode_success(&response, "create-user", true)?;
506        if response.id.is_empty() {
507            return Err(ClientError::Protocol {
508                diagnostic: None,
509                message: "the create-user response contained an empty id".to_owned(),
510                success_status_received: true,
511            });
512        }
513        Ok(CreateUserResult {
514            user_id: response.id,
515        })
516    }
517
518    pub async fn user_boards(
519        &self,
520        user_id: &str,
521        token: &SecretString,
522    ) -> Result<Vec<BoardSummary>, ClientError> {
523        let endpoint = self.user_endpoint(user_id, Some("boards"))?;
524        let body = self
525            .execute_success_request(
526                self.http
527                    .get(endpoint)
528                    .header(ACCEPT, "application/json")
529                    .bearer_auth(token.expose_secret()),
530            )
531            .await?;
532        decode_success(&body, "user boards", true)
533    }
534
535    pub async fn user_action(
536        &self,
537        user_id: &str,
538        action: UserAction,
539        token: &SecretString,
540    ) -> Result<UserActionResult, ClientError> {
541        let endpoint = self.user_endpoint(user_id, None)?;
542        let body = self
543            .execute_success_request(
544                self.http
545                    .put(endpoint)
546                    .header(ACCEPT, "application/json")
547                    .bearer_auth(token.expose_secret())
548                    .json(&UserActionRequestBody {
549                        action: action.as_wire_value(),
550                    }),
551            )
552            .await?;
553        match action {
554            UserAction::TakeOwnership => Ok(UserActionResult::OwnershipTransferred(
555                decode_success(&body, "take ownership", true)?,
556            )),
557            UserAction::DisableLogin | UserAction::EnableLogin => {
558                let user = decode_success(&body, "login action", true)?;
559                Ok(UserActionResult::LoginChanged(Box::new(
560                    validate_user_record(user, "login action")?,
561                )))
562            }
563        }
564    }
565
566    pub async fn delete_user(
567        &self,
568        user_id: &str,
569        token: &SecretString,
570    ) -> Result<String, ClientError> {
571        let endpoint = self.user_endpoint(user_id, None)?;
572        let body = self
573            .execute_success_request(
574                self.http
575                    .delete(endpoint)
576                    .header(ACCEPT, "application/json")
577                    .bearer_auth(token.expose_secret()),
578            )
579            .await?;
580        let response: IdResponse = decode_success(&body, "delete-user", true)?;
581        if response.id.is_empty() {
582            return Err(ClientError::Protocol {
583                diagnostic: None,
584                message: "the delete-user response contained an empty id".to_owned(),
585                success_status_received: true,
586            });
587        }
588        Ok(response.id)
589    }
590
591    fn endpoint(&self, path: &str, operation: &str) -> Result<reqwest::Url, ClientError> {
592        self.server()
593            .join(path)
594            .map_err(|error| ClientError::Protocol {
595                diagnostic: None,
596                message: format!("could not build the {operation} endpoint: {error}"),
597                success_status_received: false,
598            })
599    }
600
601    fn user_endpoint(
602        &self,
603        selector: &str,
604        suffix: Option<&str>,
605    ) -> Result<reqwest::Url, ClientError> {
606        let mut endpoint = self.endpoint("api/users", "user")?;
607        endpoint
608            .path_segments_mut()
609            .map_err(|()| ClientError::Protocol {
610                diagnostic: None,
611                message: "could not add the user selector to the endpoint".to_owned(),
612                success_status_received: false,
613            })?
614            .push(selector);
615        if let Some(suffix) = suffix {
616            endpoint
617                .path_segments_mut()
618                .expect("an HTTP endpoint with a host supports path segments")
619                .push(suffix);
620        }
621        Ok(endpoint)
622    }
623}
624
625fn validate_user_record(user: UserRecord, operation: &str) -> Result<UserRecord, ClientError> {
626    if user.user_id.is_empty() {
627        Err(ClientError::Protocol {
628            diagnostic: None,
629            message: format!("the {operation} response contained an empty user id"),
630            success_status_received: true,
631        })
632    } else {
633        Ok(user)
634    }
635}
636
637#[cfg(test)]
638mod strict_response_tests {
639    use serde_json::json;
640
641    use super::{IdResponse, UserCard, UserRecord, UserSummary};
642
643    #[test]
644    fn user_responses_accept_mapped_dynamic_properties() {
645        let user = serde_json::from_value::<UserRecord>(json!({
646            "_id": "user-1",
647            "profile": {
648                "fullname": "Alice",
649                "collapsedWorkspaces": {"workspace-1": true},
650                "boardWorkspaceAssignments": {"board-1": "workspace-1"},
651                "moveAndCopyDialog": {"board-1": {
652                    "boardId":"board-2", "swimlaneId":"lane-1", "listId":"list-1", "cardId":null
653                }},
654                "listWidths": {"board-1": {"list-1": 320}},
655                "listConstraints": {"board-1": {"list-1": 550}}
656            }
657        }))
658        .unwrap();
659
660        assert_eq!(user.user_id(), "user-1");
661        assert_eq!(user.full_name(), Some("Alice"));
662    }
663
664    #[test]
665    fn user_responses_reject_unmapped_top_level_and_nested_fields() {
666        assert!(
667            serde_json::from_value::<UserRecord>(json!({
668                "_id": "user-1",
669                "unexpected": true
670            }))
671            .is_err()
672        );
673        assert!(
674            serde_json::from_value::<UserRecord>(json!({
675                "_id": "user-1",
676                "profile": {"unexpected": true}
677            }))
678            .is_err()
679        );
680        assert!(
681            serde_json::from_value::<UserSummary>(json!({
682                "_id": "user-1",
683                "unexpected": true
684            }))
685            .is_err()
686        );
687        assert!(
688            serde_json::from_value::<UserCard>(json!({
689                "_id": "card-1",
690                "unexpected": true
691            }))
692            .is_err()
693        );
694        assert!(
695            serde_json::from_value::<IdResponse>(json!({
696                "_id": "user-1",
697                "unexpected": true
698            }))
699            .is_err()
700        );
701    }
702}
703
704#[derive(Clone, Debug)]
705pub enum UserPassword {
706    Plaintext(SecretString),
707    Sha256(SecretString),
708}
709
710#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
711pub enum PasswordDigestError {
712    #[error("invalid password digest object")]
713    InvalidObject,
714    #[error("password digest must use sha-256 and contain 64 UTF-16 code units")]
715    InvalidDigest,
716}
717impl From<SecretString> for UserPassword {
718    fn from(value: SecretString) -> Self {
719        Self::Plaintext(value)
720    }
721}
722impl UserPassword {
723    pub fn secret(&self) -> &SecretString {
724        match self {
725            Self::Plaintext(secret) | Self::Sha256(secret) => secret,
726        }
727    }
728    pub fn from_digest_json(input: &SecretString) -> Result<Self, PasswordDigestError> {
729        #[derive(Deserialize)]
730        #[serde(deny_unknown_fields)]
731        struct Digest {
732            digest: String,
733            algorithm: String,
734        }
735        let value: Digest = serde_json::from_str(input.expose_secret())
736            .map_err(|_| PasswordDigestError::InvalidObject)?;
737        if value.algorithm != "sha-256" || value.digest.encode_utf16().count() != 64 {
738            return Err(PasswordDigestError::InvalidDigest);
739        }
740        Ok(Self::Sha256(SecretString::from(value.digest)))
741    }
742}
743impl Serialize for UserPassword {
744    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
745        match self {
746            Self::Plaintext(value) => serializer.serialize_str(value.expose_secret()),
747            Self::Sha256(value) => {
748                use serde::ser::SerializeStruct;
749                let mut object = serializer.serialize_struct("PasswordDigest", 2)?;
750                object.serialize_field("digest", value.expose_secret())?;
751                object.serialize_field("algorithm", "sha-256")?;
752                object.end()
753            }
754        }
755    }
756}