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 #[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}