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 Constraint::Length(1),
140 ];
141
142 if self.justification.is_some() {
144 sections.push(Constraint::Length(3));
145 }
146
147 sections.push(Constraint::Min(5));
149
150 if self.duration.is_some() {
152 sections.push(Constraint::Length(3));
153 }
154
155 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 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 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 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 let app = App::new(items, justification, duration)?;
390 let res = app.run(&mut terminal);
391
392 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}