Skip to main content

azure_pim_cli/
interactive.rs

1use crate::models::roles::RoleAssignment;
2use anyhow::{anyhow, Result};
3use ratatui::{
4    crossterm::{
5        event::{
6            self, Event,
7            KeyCode::{BackTab, Backspace, Char, Down, Enter, Esc, Tab, Up},
8            KeyEventKind,
9        },
10        execute,
11        terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
12    },
13    prelude::*,
14    widgets::{
15        Block, BorderType, HighlightSpacing, Paragraph, Row, ScrollbarState, Table, TableState,
16    },
17};
18use std::{collections::BTreeSet, io::stdout};
19
20const ENABLED: &str = " ✓ ";
21const DISABLED: &str = " ☐ ";
22const TITLE_TEXT: &str = "Activate Azure PIM roles";
23const JUSTIFICATION_TEXT: &str = "Type to enter justification";
24const SCOPE_TEXT: &str = "↑ or ↓ to move | Space to toggle";
25const DURATION_TEXT: &str = "↑ or ↓ to update duration";
26const ALL_HELP: &str = "Tab or Shift-Tab to change sections | Enter to activate | Esc to quit";
27const ITEM_HEIGHT: u16 = 2;
28
29pub struct Selected {
30    pub assignments: BTreeSet<RoleAssignment>,
31    pub justification: String,
32    pub duration: u64,
33}
34
35struct Entry {
36    value: RoleAssignment,
37    enabled: bool,
38}
39
40#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
41enum InputState {
42    Duration,
43    Justification,
44    Scopes,
45}
46
47struct App {
48    duration: Option<u64>,
49    input_state: InputState,
50    table_state: TableState,
51    justification: Option<String>,
52    items: Vec<Entry>,
53    longest_item_lens: (u16, u16),
54    scroll_state: ScrollbarState,
55    warnings: Vec<String>,
56}
57
58impl App {
59    fn new(
60        assignments: BTreeSet<RoleAssignment>,
61        justification: Option<String>,
62        duration: Option<u64>,
63    ) -> Result<Self> {
64        Ok(Self {
65            duration,
66            input_state: if justification.is_none() {
67                InputState::Scopes
68            } else {
69                InputState::Justification
70            },
71            table_state: TableState::default().with_selected(0),
72            justification,
73            longest_item_lens: column_widths(&assignments)?,
74            scroll_state: ScrollbarState::new((assignments.len() - 1) * usize::from(ITEM_HEIGHT)),
75            items: assignments
76                .into_iter()
77                .map(|value| Entry {
78                    value,
79                    enabled: false,
80                })
81                .collect(),
82            warnings: Vec::new(),
83        })
84    }
85
86    fn toggle_current(&mut self) {
87        if let Some(i) = self.table_state.selected() {
88            if let Some(item) = self.items.get_mut(i) {
89                item.enabled = !item.enabled;
90            }
91        }
92    }
93
94    pub fn next(&mut self) {
95        let i = match self.table_state.selected() {
96            Some(i) => {
97                if i >= self.items.len() - 1 {
98                    0
99                } else {
100                    i + 1
101                }
102            }
103            None => 0,
104        };
105        self.table_state.select(Some(i));
106        self.scroll_state = self.scroll_state.position(i * usize::from(ITEM_HEIGHT));
107    }
108
109    pub fn previous(&mut self) {
110        let i = match self.table_state.selected() {
111            Some(i) => {
112                if i == 0 {
113                    self.items.len() - 1
114                } else {
115                    i - 1
116                }
117            }
118            None => 0,
119        };
120        self.table_state.select(Some(i));
121        self.scroll_state = self.scroll_state.position(i * usize::from(ITEM_HEIGHT));
122    }
123
124    fn check(&mut self) {
125        self.warnings.clear();
126        if self.justification.as_ref().is_some_and(String::is_empty) {
127            self.warnings.push("Justification is required".to_string());
128        }
129        if self.items.iter().all(|x| !x.enabled) {
130            self.warnings
131                .push("At least one role must be selected".to_string());
132        }
133    }
134
135    #[allow(clippy::indexing_slicing)]
136    fn draw(&mut self, f: &mut Frame) {
137        let mut sections = vec![
138            // title
139            Constraint::Length(1),
140        ];
141
142        // justification
143        if self.justification.is_some() {
144            sections.push(Constraint::Length(3));
145        }
146
147        // roles
148        sections.push(Constraint::Min(5));
149
150        // duration
151        if self.duration.is_some() {
152            sections.push(Constraint::Length(3));
153        }
154
155        // footer
156        sections.push(Constraint::Length(4));
157
158        if !self.warnings.is_empty() {
159            sections.push(Constraint::Length(
160                2 + u16::try_from(self.warnings.len()).unwrap_or(0),
161            ));
162        }
163
164        let rects = Layout::vertical(sections).split(f.area());
165        let mut rects = rects.iter();
166
167        // from here forward, if the next() call fails, we return early as the
168        // rect is missing
169        let Some(title) = rects.next() else {
170            return;
171        };
172        Self::render_title(f, *title);
173
174        if self.justification.is_some() {
175            let Some(justification) = rects.next() else {
176                return;
177            };
178            self.render_justification(f, *justification);
179        }
180
181        let Some(scopes) = rects.next() else {
182            return;
183        };
184        self.render_scopes(f, *scopes);
185
186        if self.duration.is_some() {
187            let Some(duration) = rects.next() else {
188                return;
189            };
190            self.render_duration(f, *duration);
191        }
192
193        let Some(footer) = rects.next() else {
194            return;
195        };
196        self.render_footer(f, *footer);
197
198        if !self.warnings.is_empty() {
199            let Some(warnings) = rects.next() else {
200                return;
201            };
202            self.render_warnings(f, *warnings);
203        }
204    }
205
206    fn render_warnings(&self, frame: &mut Frame, area: Rect) {
207        frame.render_widget(
208            Paragraph::new(self.warnings.join("\n"))
209                .style(Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED))
210                .alignment(Alignment::Center)
211                .block(Block::bordered().title("Warnings!")),
212            area,
213        );
214    }
215
216    fn render_title(frame: &mut Frame, area: Rect) {
217        frame.render_widget(
218            Paragraph::new(TITLE_TEXT)
219                .style(Style::default().add_modifier(Modifier::BOLD))
220                .alignment(Alignment::Center),
221            area,
222        );
223    }
224
225    fn render_duration(&mut self, frame: &mut Frame, area: Rect) {
226        // Style::default().add_modifier(Modifier::REVERSED)
227        frame.render_widget(
228            Paragraph::new(format!("{} minutes", self.duration.unwrap_or_default()))
229                .style(if self.input_state == InputState::Duration {
230                    Style::default().add_modifier(Modifier::REVERSED)
231                } else {
232                    Style::default()
233                })
234                .block(Block::bordered().title("Duration")),
235            area,
236        );
237    }
238
239    fn render_justification(&mut self, frame: &mut Frame, area: Rect) {
240        let justification = self.justification.clone().unwrap_or_default();
241        frame.render_widget(
242            Paragraph::new(justification.clone()).block(Block::bordered().title("Justification")),
243            area,
244        );
245        if self.input_state == InputState::Justification {
246            #[allow(clippy::cast_possible_truncation)]
247            frame.set_cursor_position((area.x + justification.len() as u16 + 1, area.y + 1));
248        }
249    }
250
251    fn render_scopes(&mut self, frame: &mut Frame, area: Rect) {
252        frame.render_stateful_widget(
253            Table::new(
254                self.items.iter().map(|data| {
255                    Row::new(vec![
256                        format!(
257                            "{} {}",
258                            if data.enabled { ENABLED } else { DISABLED },
259                            data.value.role
260                        ),
261                        if let Some(scope_name) = data.value.scope_name.as_deref() {
262                            format!("{scope_name}\n{}", data.value.scope)
263                        } else {
264                            data.value.scope.to_string()
265                        },
266                    ])
267                    .height(ITEM_HEIGHT)
268                }),
269                [
270                    Constraint::Length(self.longest_item_lens.0 + 4),
271                    Constraint::Min(self.longest_item_lens.1 + 1),
272                ],
273            )
274            .header(
275                ["Role", "Scope"]
276                    .into_iter()
277                    .collect::<Row>()
278                    .style(Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED))
279                    .height(1),
280            )
281            .row_highlight_style(if self.input_state == InputState::Scopes {
282                Style::default().add_modifier(Modifier::REVERSED)
283            } else {
284                Style::default()
285            })
286            .highlight_spacing(HighlightSpacing::Always)
287            .block(Block::bordered().title("Scopes")),
288            area,
289            &mut self.table_state,
290        );
291    }
292
293    fn render_footer(&self, f: &mut Frame, area: Rect) {
294        f.render_widget(
295            Paragraph::new(format!(
296                "{}\n{ALL_HELP}",
297                match self.input_state {
298                    InputState::Duration => DURATION_TEXT,
299                    InputState::Justification => JUSTIFICATION_TEXT,
300                    InputState::Scopes => SCOPE_TEXT,
301                }
302            ))
303            .style(Style::new())
304            .centered()
305            .block(
306                Block::bordered()
307                    .title("Help")
308                    .border_type(BorderType::Double)
309                    .border_style(Style::new()),
310            ),
311            area,
312        );
313    }
314
315    fn run<B: Backend>(mut self, terminal: &mut Terminal<B>) -> Result<Option<Selected>> {
316        self.check();
317        loop {
318            terminal
319                .draw(|f| self.draw(f))
320                .map_err(|e| anyhow!("Failed to draw terminal: {e}"))?;
321
322            if let Event::Key(key) = event::read()? {
323                if key.kind == KeyEventKind::Press {
324                    match (self.input_state, key.code) {
325                        (InputState::Justification, Tab) | (InputState::Duration, BackTab) => {
326                            self.input_state = InputState::Scopes;
327                        }
328                        (InputState::Scopes, Tab) | (InputState::Justification, BackTab) => {
329                            self.input_state = InputState::Duration;
330                        }
331                        (InputState::Duration, Tab) | (InputState::Scopes, BackTab) => {
332                            self.input_state = InputState::Justification;
333                        }
334                        (InputState::Justification, Char(c)) => {
335                            if let Some(justification) = &mut self.justification {
336                                justification.push(c);
337                            }
338                        }
339                        (InputState::Justification, Backspace) => {
340                            if let Some(justification) = &mut self.justification {
341                                justification.pop();
342                            }
343                        }
344                        (InputState::Duration, Down) => {
345                            self.duration = self.duration.map(|x| x.saturating_sub(1).max(1));
346                        }
347                        (InputState::Duration, Up) => {
348                            self.duration = self.duration.map(|x| x.saturating_add(1).min(480));
349                        }
350                        (InputState::Scopes, Char(' ')) => self.toggle_current(),
351                        (InputState::Scopes, Down) => self.next(),
352                        (InputState::Scopes, Up) => self.previous(),
353                        (_, Esc) => return Ok(None),
354                        (_, Enter) if self.warnings.is_empty() => {
355                            let assignments = self
356                                .items
357                                .into_iter()
358                                .filter(|entry| entry.enabled)
359                                .map(|entry| entry.value)
360                                .collect();
361                            return Ok(Some(Selected {
362                                assignments,
363                                justification: self.justification.unwrap_or_default(),
364                                duration: self.duration.unwrap_or_default(),
365                            }));
366                        }
367                        _ => {}
368                    }
369                }
370            }
371            self.check();
372        }
373    }
374}
375
376pub fn interactive_ui(
377    items: BTreeSet<RoleAssignment>,
378    justification: Option<String>,
379    duration: Option<u64>,
380) -> Result<Option<Selected>> {
381    // setup terminal
382    enable_raw_mode()?;
383    let mut stdout = stdout();
384    execute!(stdout, EnterAlternateScreen)?;
385    let backend = CrosstermBackend::new(stdout);
386    let mut terminal = Terminal::new(backend)?;
387
388    // create app and run it
389    let app = App::new(items, justification, duration)?;
390    let res = app.run(&mut terminal);
391
392    // restore terminal
393    disable_raw_mode()?;
394    execute!(terminal.backend_mut(), LeaveAlternateScreen,)?;
395    terminal.show_cursor()?;
396
397    res
398}
399
400fn column_widths(items: &BTreeSet<RoleAssignment>) -> Result<(u16, u16)> {
401    let (scope_name_len, role_len, scope_len) =
402        items
403            .iter()
404            .fold((0, 0, 0), |(scope_name_len, role_len, scope_len), x| {
405                (
406                    scope_name_len.max(x.scope_name.as_deref().map_or(0, str::len)),
407                    role_len.max(x.role.0.len()),
408                    scope_len.max(x.scope.0.len()),
409                )
410            });
411
412    Ok((
413        role_len.try_into()?,
414        scope_name_len.max(scope_len).try_into()?,
415    ))
416}