multiverse/widgets/recovery/
recovering.rs

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