multiverse/widgets/recovery/
recovering.rs

1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2use futures_util::FutureExt as _;
3use matrix_sdk::{
4    encryption::{recovery::RecoveryError, CrossSigningResetAuthType},
5    reqwest::Url,
6    ruma::api::client::uiaa::{AuthData, Password},
7    Client,
8};
9use ratatui::{
10    prelude::*,
11    widgets::{Block, Paragraph},
12};
13use throbber_widgets_tui::{Throbber, ThrobberState};
14use tokio::{
15    sync::{
16        mpsc::{unbounded_channel, UnboundedSender},
17        oneshot,
18    },
19    task::JoinHandle,
20};
21use tui_textarea::TextArea;
22
23use super::ShouldExit;
24use crate::widgets::{recovery::create_centered_throbber_area, Hyperlink};
25
26#[derive(Debug)]
27enum ResetState {
28    Waiting { receiver: oneshot::Receiver<ResetMessage> },
29    ResettingOauth { approval_url: Url },
30    InputtingMatrixAuthInfo { sender: UnboundedSender<String>, text_area: TextArea<'static> },
31    ResettingMatrixAuth,
32    Done,
33}
34
35#[derive(Debug)]
36enum ResetMessage {
37    Oauth { approval_url: Url },
38    MatrixAuth { password_sender: UnboundedSender<String> },
39}
40
41#[derive(Debug)]
42pub struct RecoveringView {
43    client: Client,
44    mode: Mode,
45}
46
47#[derive(Debug)]
48enum Mode {
49    Recovering {
50        recovery_task: JoinHandle<Result<(), RecoveryError>>,
51        throbber_state: ThrobberState,
52    },
53    Resetting {
54        reset_state: ResetState,
55        reset_task: JoinHandle<Result<(), RecoveryError>>,
56        throbber_state: ThrobberState,
57    },
58    Inputting {
59        recovery_text_area: TextArea<'static>,
60    },
61    Done {
62        result: Result<(), RecoveryError>,
63    },
64}
65
66impl RecoveringView {
67    pub fn new(client: Client) -> Self {
68        let mut recovery_text_area = TextArea::default();
69
70        recovery_text_area.set_cursor_line_style(Style::default());
71        recovery_text_area.set_mask_char('\u{2022}'); //U+2022 BULLET (•)
72        recovery_text_area.set_placeholder_text("To enable recovery enter the recover key");
73
74        recovery_text_area.set_style(Style::default().fg(Color::LightGreen));
75        recovery_text_area.set_block(Block::default());
76
77        Self { client, mode: Mode::Inputting { recovery_text_area } }
78    }
79
80    fn update(&mut self) {
81        use Mode::*;
82
83        match &mut self.mode {
84            Recovering { recovery_task, .. } => {
85                if recovery_task.is_finished() {
86                    let result = recovery_task
87                        .now_or_never()
88                        .expect("The task should have finished, we checked it")
89                        .expect("The recovery enabling task should neve panic");
90                    self.mode = Done { result };
91                }
92            }
93            Resetting { reset_state, reset_task, .. } => match reset_state {
94                ResetState::Waiting { receiver } => {
95                    match receiver.try_recv() {
96                        Ok(ResetMessage::Oauth { approval_url }) => {
97                            *reset_state = ResetState::ResettingOauth { approval_url };
98                        }
99                        Ok(ResetMessage::MatrixAuth { password_sender }) => {
100                            let mut text_area = TextArea::default();
101
102                            text_area.set_cursor_line_style(Style::default());
103                            text_area.set_mask_char('\u{2022}'); //U+2022 BULLET (•)
104                            text_area
105                                .set_placeholder_text("To reset your identity enter your password");
106
107                            text_area.set_style(Style::default().fg(Color::LightGreen));
108                            text_area.set_block(Block::default());
109
110                            *reset_state = ResetState::InputtingMatrixAuthInfo {
111                                sender: password_sender,
112                                text_area,
113                            };
114                        }
115                        _ => {}
116                    }
117                }
118                ResetState::InputtingMatrixAuthInfo { .. } | ResetState::Done => {}
119                ResetState::ResettingOauth { .. } | ResetState::ResettingMatrixAuth => {
120                    if reset_task.is_finished() {
121                        *reset_state = ResetState::Done;
122                    }
123                }
124            },
125            Inputting { .. } | Done { .. } => {}
126        }
127    }
128
129    pub fn on_tick(&mut self) {
130        use Mode::*;
131
132        match &mut self.mode {
133            Recovering { throbber_state, .. } => throbber_state.calc_next(),
134            Resetting { throbber_state, .. } => throbber_state.calc_next(),
135            Inputting { .. } | Done { .. } => {}
136        }
137    }
138
139    pub fn is_idle(&self) -> bool {
140        match self.mode {
141            Mode::Recovering { .. } | Mode::Resetting { .. } | Mode::Done { .. } => false,
142            Mode::Inputting { .. } => true,
143        }
144    }
145
146    fn handle_identity_reset(&mut self) {
147        let client = self.client.clone();
148        let (sender, receiver) = oneshot::channel();
149
150        let user_id = client
151            .user_id()
152            .expect("We should have access to our user ID if we're resetting our identity")
153            .to_owned();
154
155        let reset_task = tokio::spawn(async move {
156            let handle = client.encryption().recovery().reset_identity().await?;
157
158            if let Some(handle) = handle {
159                match handle.auth_type() {
160                    CrossSigningResetAuthType::Uiaa(_) => {
161                        let (password_sender, mut password_receiver) = unbounded_channel();
162                        let _ = sender.send(ResetMessage::MatrixAuth { password_sender });
163
164                        let password = password_receiver
165                            .recv()
166                            .await
167                            .expect("The sender should not have been closed");
168
169                        handle
170                            .reset(Some(AuthData::Password(Password::new(
171                                user_id.into(),
172                                password,
173                            ))))
174                            .await
175                    }
176                    CrossSigningResetAuthType::OAuth(oauth_cross_signing_reset_info) => {
177                        sender
178                            .send(ResetMessage::Oauth {
179                                approval_url: oauth_cross_signing_reset_info.approval_url.clone(),
180                            })
181                            .expect("");
182                        handle.reset(None).await
183                    }
184                }
185            } else {
186                Ok(())
187            }
188        });
189
190        let reset_state = ResetState::Waiting { receiver };
191
192        self.mode =
193            Mode::Resetting { reset_state, reset_task, throbber_state: ThrobberState::default() };
194    }
195
196    pub fn handle_key(&mut self, key: KeyEvent) -> ShouldExit {
197        use KeyCode::*;
198        use Mode::*;
199        use ShouldExit::*;
200
201        match &mut self.mode {
202            Recovering { .. } => No,
203            Resetting { reset_state, .. } => match reset_state {
204                ResetState::Waiting { .. }
205                | ResetState::ResettingOauth { .. }
206                | ResetState::ResettingMatrixAuth => match (key.modifiers, key.code) {
207                    (_, Esc) => {
208                        *self = Self::new(self.client.clone());
209                        No
210                    }
211                    _ => No,
212                },
213                ResetState::InputtingMatrixAuthInfo { sender, text_area } => {
214                    match (key.modifiers, key.code) {
215                        (_, Enter) => {
216                            let password = text_area.lines().join("");
217                            sender
218                                .send(password)
219                                .expect("The task should still wait for the password");
220
221                            *reset_state = ResetState::ResettingMatrixAuth;
222
223                            No
224                        }
225                        _ => {
226                            text_area.input(key);
227                            No
228                        }
229                    }
230                }
231
232                ResetState::Done => OnlySubScreen,
233            },
234
235            Inputting { recovery_text_area } => {
236                match (key.modifiers, key.code) {
237                    (KeyModifiers::CONTROL, Char('r')) => {
238                        self.handle_identity_reset();
239                        No
240                    }
241                    (_, Esc) => Yes,
242                    (_, Enter) => {
243                        // We expect a single line since pressing enter gets us here, still, let's
244                        // just join all the lines into a single one.
245                        let recovery_key = recovery_text_area.lines().join("");
246                        let client = self.client.clone();
247
248                        let recovery_task = tokio::spawn(async move {
249                            client.encryption().recovery().recover(recovery_key.trim()).await
250                        });
251
252                        self.mode =
253                            Recovering { recovery_task, throbber_state: ThrobberState::default() };
254
255                        No
256                    }
257                    _ => {
258                        recovery_text_area.input(key);
259
260                        No
261                    }
262                }
263            }
264            Done { .. } => OnlySubScreen,
265        }
266    }
267}
268
269impl Widget for &mut RecoveringView {
270    fn render(self, area: Rect, buf: &mut Buffer)
271    where
272        Self: Sized,
273    {
274        use Mode::*;
275
276        self.update();
277
278        match &mut self.mode {
279            Recovering { throbber_state, .. } => {
280                let throbber = Throbber::default()
281                    .label("Recovering")
282                    .throbber_set(throbber_widgets_tui::BRAILLE_EIGHT_DOUBLE);
283                let centered_area = create_centered_throbber_area(area);
284                StatefulWidget::render(throbber, centered_area, buf, throbber_state);
285            }
286
287            Resetting { throbber_state, reset_state, .. } => match reset_state {
288                ResetState::InputtingMatrixAuthInfo { text_area, .. } => {
289                    let [left, right] =
290                        Layout::horizontal([Constraint::Length(14), Constraint::Length(50)])
291                            .areas(area);
292
293                    Paragraph::new("Password:").render(left, buf);
294                    text_area.render(right, buf);
295                }
296                ResetState::ResettingOauth { approval_url } => {
297                    let chunks = Layout::default()
298                        .direction(Direction::Horizontal)
299                        .margin(1)
300                        .constraints([
301                            Constraint::Fill(1),
302                            Constraint::Length(65),
303                            Constraint::Fill(1),
304                        ])
305                        .split(area);
306
307                    let chunks = Layout::default()
308                        .direction(Direction::Vertical)
309                        .margin(1)
310                        .constraints([
311                            Constraint::Fill(1),
312                            Constraint::Length(1),
313                            Constraint::Fill(1),
314                        ])
315                        .split(chunks[1]);
316
317                    let centered_area = chunks[1];
318
319                    let [left, right] =
320                        Layout::horizontal([Constraint::Length(38), Constraint::Length(22)])
321                            .areas(centered_area);
322
323                    let hyperlink = Hyperlink::new(
324                        Text::from("account management URL").blue(),
325                        approval_url.to_string(),
326                    );
327
328                    Text::from("To finish the reset approve it at the ").render(left, buf);
329                    hyperlink.render(right, buf);
330                }
331                ResetState::Waiting { .. } | ResetState::ResettingMatrixAuth => {
332                    let throbber = Throbber::default()
333                        .label("Resetting your identity")
334                        .throbber_set(throbber_widgets_tui::BRAILLE_EIGHT_DOUBLE);
335                    let centered_area = create_centered_throbber_area(area);
336                    StatefulWidget::render(throbber, centered_area, buf, throbber_state);
337                }
338                ResetState::Done => {
339                    let constraints =
340                        [Constraint::Fill(1), Constraint::Min(3), Constraint::Fill(1)];
341                    let [_top, middle, _bottom] = Layout::vertical(constraints).areas(area);
342
343                    Paragraph::new("Done resetting\n\nPress any key to continue")
344                        .centered()
345                        .render(middle, buf);
346                }
347            },
348
349            Inputting { recovery_text_area } => {
350                let [left, right] =
351                    Layout::horizontal([Constraint::Length(14), Constraint::Length(50)])
352                        .areas(area);
353
354                Paragraph::new("Recovery key: ").render(left, buf);
355                recovery_text_area.render(right, buf);
356            }
357
358            Done { result } => {
359                let constraints = [Constraint::Fill(1), Constraint::Min(3), Constraint::Fill(1)];
360                let [_top, middle, _bottom] = Layout::vertical(constraints).areas(area);
361
362                match result {
363                    Ok(_) => {
364                        Paragraph::new("Done recovering\n\nPress any key to continue")
365                            .centered()
366                            .render(middle, buf);
367                    }
368                    Err(error) => {
369                        Paragraph::new(format!(
370                            "Error recovering: {error:?}\n\nPress any key to continue"
371                        ))
372                        .centered()
373                        .render(middle, buf);
374                    }
375                }
376            }
377        }
378    }
379}