Skip to main content

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, WidgetRef as _},
13};
14use ratatui_textarea::TextArea;
15use throbber_widgets_tui::{Throbber, ThrobberState};
16use tokio::{
17    sync::{
18        mpsc::{UnboundedSender, unbounded_channel},
19        oneshot,
20    },
21    task::JoinHandle,
22};
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
245                        // here, still, let's just join all the lines into a
246                        // single one.
247                        let recovery_key = recovery_text_area.lines().join("");
248                        let client = self.client.clone();
249
250                        let recovery_task = spawn(async move {
251                            client.encryption().recovery().recover(recovery_key.trim()).await
252                        });
253
254                        self.mode =
255                            Recovering { recovery_task, throbber_state: ThrobberState::default() };
256
257                        No
258                    }
259                    _ => {
260                        recovery_text_area.input(key);
261
262                        No
263                    }
264                }
265            }
266            Done { .. } => OnlySubScreen,
267        }
268    }
269}
270
271impl Widget for &mut RecoveringView {
272    fn render(self, area: Rect, buf: &mut Buffer)
273    where
274        Self: Sized,
275    {
276        use Mode::*;
277
278        self.update();
279
280        match &mut self.mode {
281            Recovering { throbber_state, .. } => {
282                let throbber = Throbber::default()
283                    .label("Recovering")
284                    .throbber_set(throbber_widgets_tui::BRAILLE_EIGHT_DOUBLE);
285                let centered_area = create_centered_throbber_area(area);
286                StatefulWidget::render(throbber, centered_area, buf, throbber_state);
287            }
288
289            Resetting { throbber_state, reset_state, .. } => match reset_state {
290                ResetState::InputtingMatrixAuthInfo { text_area, .. } => {
291                    let [left, right] =
292                        Layout::horizontal([Constraint::Length(14), Constraint::Length(50)])
293                            .areas(area);
294
295                    Paragraph::new("Password:").render(left, buf);
296                    text_area.render(right, buf);
297                }
298                ResetState::ResettingOauth { approval_url } => {
299                    let chunks = Layout::default()
300                        .direction(Direction::Horizontal)
301                        .margin(1)
302                        .constraints([
303                            Constraint::Fill(1),
304                            Constraint::Length(65),
305                            Constraint::Fill(1),
306                        ])
307                        .split(area);
308
309                    let chunks = Layout::default()
310                        .direction(Direction::Vertical)
311                        .margin(1)
312                        .constraints([
313                            Constraint::Fill(1),
314                            Constraint::Length(1),
315                            Constraint::Fill(1),
316                        ])
317                        .split(chunks[1]);
318
319                    let centered_area = chunks[1];
320
321                    let [left, right] =
322                        Layout::horizontal([Constraint::Length(38), Constraint::Length(22)])
323                            .areas(centered_area);
324
325                    let hyperlink = Hyperlink::new(
326                        Text::from("account management URL").blue(),
327                        approval_url.to_string(),
328                    );
329
330                    Text::from("To finish the reset approve it at the ").render(left, buf);
331                    hyperlink.render_ref(right, buf);
332                }
333                ResetState::Waiting { .. } | ResetState::ResettingMatrixAuth => {
334                    let throbber = Throbber::default()
335                        .label("Resetting your identity")
336                        .throbber_set(throbber_widgets_tui::BRAILLE_EIGHT_DOUBLE);
337                    let centered_area = create_centered_throbber_area(area);
338                    StatefulWidget::render(throbber, centered_area, buf, throbber_state);
339                }
340                ResetState::Done => {
341                    let constraints =
342                        [Constraint::Fill(1), Constraint::Min(3), Constraint::Fill(1)];
343                    let [_top, middle, _bottom] = Layout::vertical(constraints).areas(area);
344
345                    Paragraph::new("Done resetting\n\nPress any key to continue")
346                        .centered()
347                        .render(middle, buf);
348                }
349            },
350
351            Inputting { recovery_text_area } => {
352                let [left, right] =
353                    Layout::horizontal([Constraint::Length(14), Constraint::Length(50)])
354                        .areas(area);
355
356                Paragraph::new("Recovery key: ").render(left, buf);
357                recovery_text_area.render(right, buf);
358            }
359
360            Done { result } => {
361                let constraints = [Constraint::Fill(1), Constraint::Min(3), Constraint::Fill(1)];
362                let [_top, middle, _bottom] = Layout::vertical(constraints).areas(area);
363
364                match result {
365                    Ok(_) => {
366                        Paragraph::new("Done recovering\n\nPress any key to continue")
367                            .centered()
368                            .render(middle, buf);
369                    }
370                    Err(error) => {
371                        Paragraph::new(format!(
372                            "Error recovering: {error:?}\n\nPress any key to continue"
373                        ))
374                        .centered()
375                        .render(middle, buf);
376                    }
377                }
378            }
379        }
380    }
381}