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}'); 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}'); 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 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}