1use crate::app::{App, ViewMode};
2use crate::common::CyclicEnum;
3use crate::common::{render_pagination_text, InputFocus, SortDirection};
4use crate::iam::{
5 GroupUser, IamGroup, IamRole, IamUser, LastAccessedService, Policy, RoleTag, UserGroup, UserTag,
6};
7use crate::keymap::Mode;
8use crate::table::TableState;
9use crate::ui::table::Column;
10use crate::ui::{
11 active_border, block_height_for, filter_area, get_cursor, labeled_field,
12 render_json_highlighted, render_last_accessed_section, render_permissions_section, render_tabs,
13 render_tags_section, vertical,
14};
15use ratatui::{prelude::*, widgets::*};
16
17pub const POLICY_TYPE_DROPDOWN: InputFocus = InputFocus::Dropdown("PolicyType");
18pub const POLICY_FILTER_CONTROLS: [InputFocus; 3] = [
19 InputFocus::Filter,
20 POLICY_TYPE_DROPDOWN,
21 InputFocus::Pagination,
22];
23
24pub const ROLE_FILTER_CONTROLS: [InputFocus; 2] = [InputFocus::Filter, InputFocus::Pagination];
25
26#[derive(Debug, Clone, Copy, PartialEq)]
27pub enum UserTab {
28 Permissions,
29 Groups,
30 Tags,
31 SecurityCredentials,
32 LastAccessed,
33}
34
35impl CyclicEnum for UserTab {
36 const ALL: &'static [Self] = &[
37 Self::Permissions,
38 Self::Groups,
39 Self::Tags,
40 Self::SecurityCredentials,
41 Self::LastAccessed,
42 ];
43}
44
45impl UserTab {
46 pub fn name(&self) -> &'static str {
47 match self {
48 UserTab::Permissions => "Permissions",
49 UserTab::Groups => "Groups",
50 UserTab::Tags => "Tags",
51 UserTab::SecurityCredentials => "Security Credentials",
52 UserTab::LastAccessed => "Last Accessed",
53 }
54 }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq)]
58pub enum RoleTab {
59 Permissions,
60 TrustRelationships,
61 Tags,
62 LastAccessed,
63 RevokeSessions,
64}
65
66impl CyclicEnum for RoleTab {
67 const ALL: &'static [Self] = &[
68 Self::Permissions,
69 Self::TrustRelationships,
70 Self::Tags,
71 Self::LastAccessed,
72 Self::RevokeSessions,
73 ];
74}
75
76impl RoleTab {
77 pub fn name(&self) -> &'static str {
78 match self {
79 RoleTab::Permissions => "Permissions",
80 RoleTab::TrustRelationships => "Trust relationships",
81 RoleTab::Tags => "Tags",
82 RoleTab::LastAccessed => "Last Accessed",
83 RoleTab::RevokeSessions => "Revoke sessions",
84 }
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq)]
89pub enum GroupTab {
90 Users,
91 Permissions,
92 AccessAdvisor,
93}
94
95impl CyclicEnum for GroupTab {
96 const ALL: &'static [Self] = &[Self::Users, Self::Permissions, Self::AccessAdvisor];
97}
98
99impl GroupTab {
100 pub fn name(&self) -> &'static str {
101 match self {
102 GroupTab::Users => "Users",
103 GroupTab::Permissions => "Permissions",
104 GroupTab::AccessAdvisor => "Access Advisor",
105 }
106 }
107}
108
109#[derive(Debug, Clone, Copy, PartialEq)]
110pub enum AccessHistoryFilter {
111 NoFilter,
112 ServicesAccessed,
113 ServicesNotAccessed,
114}
115
116impl CyclicEnum for AccessHistoryFilter {
117 const ALL: &'static [Self] = &[
118 Self::NoFilter,
119 Self::ServicesAccessed,
120 Self::ServicesNotAccessed,
121 ];
122}
123
124impl AccessHistoryFilter {
125 pub fn name(&self) -> &'static str {
126 match self {
127 AccessHistoryFilter::NoFilter => "No filter",
128 AccessHistoryFilter::ServicesAccessed => "Services accessed",
129 AccessHistoryFilter::ServicesNotAccessed => "Services not accessed",
130 }
131 }
132}
133
134pub struct State {
135 pub users: TableState<IamUser>,
136 pub current_user: Option<String>,
137 pub user_tab: UserTab,
138 pub roles: TableState<IamRole>,
139 pub current_role: Option<String>,
140 pub role_tab: RoleTab,
141 pub role_input_focus: InputFocus,
142 pub groups: TableState<IamGroup>,
143 pub current_group: Option<String>,
144 pub group_tab: GroupTab,
145 pub policies: TableState<Policy>,
146 pub policy_type_filter: String,
147 pub policy_input_focus: InputFocus,
148 pub current_policy: Option<String>,
149 pub policy_document: String,
150 pub policy_scroll: usize,
151 pub trust_policy_document: String,
152 pub trust_policy_scroll: usize,
153 pub tags: TableState<RoleTag>,
154 pub user_tags: TableState<UserTag>,
155 pub user_group_memberships: TableState<UserGroup>,
156 pub group_users: TableState<GroupUser>,
157 pub last_accessed_services: TableState<LastAccessedService>,
158 pub last_accessed_filter: String,
159 pub last_accessed_history_filter: AccessHistoryFilter,
160 pub revoke_sessions_scroll: usize,
161}
162
163impl Default for State {
164 fn default() -> Self {
165 Self::new()
166 }
167}
168
169impl State {
170 pub fn new() -> Self {
171 Self {
172 users: TableState::new(),
173 current_user: None,
174 user_tab: UserTab::Permissions,
175 roles: TableState::new(),
176 current_role: None,
177 role_tab: RoleTab::Permissions,
178 role_input_focus: InputFocus::Filter,
179 groups: TableState::new(),
180 current_group: None,
181 group_tab: GroupTab::Users,
182 policies: TableState::new(),
183 policy_type_filter: "All types".to_string(),
184 policy_input_focus: InputFocus::Filter,
185 current_policy: None,
186 policy_document: String::new(),
187 policy_scroll: 0,
188 trust_policy_document: String::new(),
189 trust_policy_scroll: 0,
190 tags: TableState::new(),
191 user_tags: TableState::new(),
192 user_group_memberships: TableState::new(),
193 group_users: TableState::new(),
194 last_accessed_services: TableState::new(),
195 last_accessed_filter: String::new(),
196 last_accessed_history_filter: AccessHistoryFilter::NoFilter,
197 revoke_sessions_scroll: 0,
198 }
199 }
200}
201
202pub fn render_users(frame: &mut Frame, app: &App, area: Rect) {
203 frame.render_widget(Clear, area);
204
205 if app.iam_state.current_user.is_some() {
206 render_user_detail(frame, app, area);
207 } else {
208 render_user_list(frame, app, area);
209 }
210}
211
212pub fn render_user_list(frame: &mut Frame, app: &App, area: Rect) {
213 let chunks = vertical(
214 [
215 Constraint::Length(1), Constraint::Length(3), Constraint::Min(0), ],
219 area,
220 );
221
222 let desc = Paragraph::new("An IAM user is an identity with long-term credentials that is used to interact with AWS in an account.")
224 .style(Style::default().fg(Color::White));
225 frame.render_widget(desc, chunks[0]);
226
227 let filtered_users = crate::ui::iam::filtered_iam_users(app);
229 let filtered_count = filtered_users.len();
230 let page_size = app.iam_state.users.page_size.value();
231 crate::ui::render_search_filter(
232 frame,
233 chunks[1],
234 &app.iam_state.users.filter,
235 app.mode == Mode::FilterInput,
236 app.iam_state.users.selected,
237 filtered_count,
238 page_size,
239 );
240
241 let scroll_offset = app.iam_state.users.scroll_offset;
243 let page_users: Vec<_> = filtered_users
244 .iter()
245 .skip(scroll_offset)
246 .take(page_size)
247 .collect();
248
249 use crate::iam::UserColumn;
250 let columns: Vec<Box<dyn Column<&IamUser>>> = app
251 .iam_user_visible_column_ids
252 .iter()
253 .filter_map(|col_id| {
254 UserColumn::from_id(col_id).map(|col| Box::new(col) as Box<dyn Column<&IamUser>>)
255 })
256 .collect();
257
258 let expanded_index = app.iam_state.users.expanded_item.and_then(|idx| {
259 if idx >= scroll_offset && idx < scroll_offset + page_size {
260 Some(idx - scroll_offset)
261 } else {
262 None
263 }
264 });
265
266 let config = crate::ui::table::TableConfig {
267 items: page_users,
268 selected_index: app.iam_state.users.selected - app.iam_state.users.scroll_offset,
269 expanded_index,
270 columns: &columns,
271 sort_column: "User name",
272 sort_direction: SortDirection::Asc,
273 title: format!(" Users ({}) ", filtered_count),
274 area: chunks[2],
275 get_expanded_content: Some(Box::new(|user: &&crate::iam::IamUser| {
276 crate::ui::table::expanded_from_columns(&columns, user)
277 })),
278 is_active: app.mode != Mode::FilterInput,
279 };
280
281 crate::ui::table::render_table(frame, config);
282}
283
284pub fn render_roles(frame: &mut Frame, app: &App, area: Rect) {
285 frame.render_widget(Clear, area);
286
287 if app.view_mode == ViewMode::PolicyView {
288 render_policy_view(frame, app, area);
289 } else if app.iam_state.current_role.is_some() {
290 render_role_detail(frame, app, area);
291 } else {
292 render_role_list(frame, app, area);
293 }
294}
295
296pub fn render_user_groups(frame: &mut Frame, app: &App, area: Rect) {
297 frame.render_widget(Clear, area);
298
299 if app.iam_state.current_group.is_some() {
300 render_group_detail(frame, app, area);
301 } else {
302 render_group_list(frame, app, area);
303 }
304}
305
306pub fn render_group_detail(frame: &mut Frame, app: &App, area: Rect) {
307 let summary_height = if app.iam_state.current_group.is_some() {
309 block_height_for(3) } else {
311 0
312 };
313
314 let chunks = vertical(
315 [
316 Constraint::Length(1), Constraint::Length(summary_height), Constraint::Length(1), Constraint::Min(0), ],
321 area,
322 );
323
324 if let Some(group_name) = &app.iam_state.current_group {
325 let label = Paragraph::new(group_name.as_str()).style(
326 Style::default()
327 .fg(Color::Yellow)
328 .add_modifier(Modifier::BOLD),
329 );
330 frame.render_widget(label, chunks[0]);
331
332 if let Some(group) = app
334 .iam_state
335 .groups
336 .items
337 .iter()
338 .find(|g| g.group_name == *group_name)
339 {
340 crate::ui::render_summary(
341 frame,
342 chunks[1],
343 " Summary ",
344 &[
345 ("User group name: ", group.group_name.clone()),
346 ("Creation time: ", group.creation_time.clone()),
347 (
348 "ARN: ",
349 format!(
350 "arn:aws:iam::{}:group/{}",
351 app.config.account_id, group.group_name
352 ),
353 ),
354 ],
355 );
356 }
357
358 let tabs: Vec<(&str, GroupTab)> =
360 GroupTab::ALL.iter().map(|tab| (tab.name(), *tab)).collect();
361 render_tabs(frame, chunks[2], &tabs, &app.iam_state.group_tab);
362
363 match app.iam_state.group_tab {
365 GroupTab::Users => {
366 render_group_users_tab(frame, app, chunks[3]);
367 }
368 GroupTab::Permissions => {
369 render_permissions_section(
370 frame,
371 chunks[3],
372 "You can attach up to 10 managed policies.",
373 |f, area| render_policies_table(f, app, area),
374 );
375 }
376 GroupTab::AccessAdvisor => {
377 render_group_access_advisor_tab(frame, app, chunks[3]);
378 }
379 }
380 }
381}
382
383pub fn render_group_users_tab(frame: &mut Frame, app: &App, area: Rect) {
384 let chunks = vertical(
385 [
386 Constraint::Length(1), Constraint::Min(0), ],
389 area,
390 );
391
392 frame.render_widget(
393 Paragraph::new("An IAM user is an entity that you create in AWS to represent the person or application that uses it to interact with AWS."),
394 chunks[0],
395 );
396
397 render_group_users_table(frame, app, chunks[1]);
398}
399
400pub fn render_group_users_table(frame: &mut Frame, app: &App, area: Rect) {
401 let chunks = vertical(
402 [
403 Constraint::Length(3), Constraint::Min(0), ],
406 area,
407 );
408
409 let page_size = app.iam_state.group_users.page_size.value();
410 let filtered_users: Vec<_> = app
411 .iam_state
412 .group_users
413 .items
414 .iter()
415 .filter(|u| {
416 if app.iam_state.group_users.filter.is_empty() {
417 true
418 } else {
419 u.user_name
420 .to_lowercase()
421 .contains(&app.iam_state.group_users.filter.to_lowercase())
422 }
423 })
424 .collect();
425
426 let filtered_count = filtered_users.len();
427 let total_pages = filtered_count.div_ceil(page_size);
428 let current_page = app.iam_state.group_users.selected / page_size;
429 let pagination = render_pagination_text(current_page, total_pages);
430
431 crate::ui::filter::render_simple_filter(
432 frame,
433 chunks[0],
434 crate::ui::filter::SimpleFilterConfig {
435 filter_text: &app.iam_state.group_users.filter,
436 placeholder: "Search",
437 pagination: &pagination,
438 mode: app.mode,
439 is_input_focused: true,
440 is_pagination_focused: false,
441 },
442 );
443
444 let scroll_offset = app.iam_state.group_users.scroll_offset;
445 let page_users: Vec<&crate::iam::GroupUser> = filtered_users
446 .into_iter()
447 .skip(scroll_offset)
448 .take(page_size)
449 .collect();
450
451 use crate::iam::GroupUserColumn;
452 let columns: Vec<Box<dyn Column<GroupUser>>> = vec![
453 Box::new(GroupUserColumn::UserName),
454 Box::new(GroupUserColumn::Groups),
455 Box::new(GroupUserColumn::LastActivity),
456 Box::new(GroupUserColumn::CreationTime),
457 ];
458
459 let expanded_index = app.iam_state.group_users.expanded_item.and_then(|idx| {
460 if idx >= scroll_offset && idx < scroll_offset + page_size {
461 Some(idx - scroll_offset)
462 } else {
463 None
464 }
465 });
466
467 let config = crate::ui::table::TableConfig {
468 items: page_users,
469 selected_index: app.iam_state.group_users.selected - scroll_offset,
470 expanded_index,
471 columns: &columns,
472 sort_column: "User name",
473 sort_direction: SortDirection::Asc,
474 title: format!(" Users ({}) ", app.iam_state.group_users.items.len()),
475 area: chunks[1],
476 is_active: app.mode != Mode::ColumnSelector,
477 get_expanded_content: Some(Box::new(|user: &crate::iam::GroupUser| {
478 crate::ui::table::plain_expanded_content(format!(
479 "User name: {}\nGroups: {}\nLast activity: {}\nCreation time: {}",
480 user.user_name, user.groups, user.last_activity, user.creation_time
481 ))
482 })),
483 };
484
485 crate::ui::table::render_table(frame, config);
486}
487
488pub fn render_group_access_advisor_tab(frame: &mut Frame, app: &App, area: Rect) {
489 let chunks = vertical(
490 [
491 Constraint::Length(1), Constraint::Min(0), ],
494 area,
495 );
496
497 frame.render_widget(
498 Paragraph::new(
499 "IAM reports activity for services and management actions. Learn more about action last accessed information. To see actions, choose the appropriate service name from the list."
500 ),
501 chunks[0],
502 );
503
504 render_last_accessed_table(frame, app, chunks[1]);
505}
506
507pub fn render_group_list(frame: &mut Frame, app: &App, area: Rect) {
508 let chunks = vertical(
509 [
510 Constraint::Length(1), Constraint::Length(3), Constraint::Min(0), ],
514 area,
515 );
516
517 let desc = Paragraph::new("A user group is a collection of IAM users. Use groups to specify permissions for a collection of users.")
518 .style(Style::default().fg(Color::White));
519 frame.render_widget(desc, chunks[0]);
520
521 let cursor = get_cursor(app.mode == Mode::FilterInput);
522 let page_size = app.iam_state.groups.page_size.value();
523 let filtered_groups: Vec<_> = app
524 .iam_state
525 .groups
526 .items
527 .iter()
528 .filter(|g| {
529 if app.iam_state.groups.filter.is_empty() {
530 true
531 } else {
532 g.group_name
533 .to_lowercase()
534 .contains(&app.iam_state.groups.filter.to_lowercase())
535 }
536 })
537 .collect();
538
539 let filtered_count = filtered_groups.len();
540 let total_pages = filtered_count.div_ceil(page_size);
541 let current_page = app.iam_state.groups.selected / page_size;
542 let pagination = render_pagination_text(current_page, total_pages);
543
544 let filter_width = (chunks[1].width as usize).saturating_sub(4);
545 let pagination_len = pagination.len();
546 let available_space = filter_width.saturating_sub(pagination_len + 1);
547
548 let mut first_line_spans = vec![];
549 if app.iam_state.groups.filter.is_empty() && app.mode != Mode::FilterInput {
550 first_line_spans.push(Span::styled("Search", Style::default().fg(Color::DarkGray)));
551 } else {
552 first_line_spans.push(Span::raw(&app.iam_state.groups.filter));
553 }
554 if app.mode == Mode::FilterInput {
555 first_line_spans.push(Span::raw(cursor));
556 }
557
558 let content_len = if app.iam_state.groups.filter.is_empty() && app.mode != Mode::FilterInput {
559 6
560 } else {
561 app.iam_state.groups.filter.len() + cursor.len()
562 };
563
564 if content_len < available_space {
565 first_line_spans.push(Span::raw(
566 " ".repeat(available_space.saturating_sub(content_len)),
567 ));
568 }
569 first_line_spans.push(Span::styled(
570 pagination,
571 Style::default().fg(Color::DarkGray),
572 ));
573
574 let filter = filter_area(first_line_spans, app.mode == Mode::FilterInput);
575 frame.render_widget(filter, chunks[1]);
576
577 let scroll_offset = app.iam_state.groups.scroll_offset;
578 let page_groups: Vec<&crate::iam::IamGroup> = filtered_groups
579 .into_iter()
580 .skip(scroll_offset)
581 .take(page_size)
582 .collect();
583
584 use crate::iam::GroupColumn;
585 let mut columns: Vec<Box<dyn Column<IamGroup>>> = vec![];
586 for col_name in &app.iam_group_visible_column_ids {
587 let column = match col_name.as_str() {
588 "Group name" => Some(GroupColumn::GroupName),
589 "Path" => Some(GroupColumn::Path),
590 "Users" => Some(GroupColumn::Users),
591 "Permissions" => Some(GroupColumn::Permissions),
592 "Creation time" => Some(GroupColumn::CreationTime),
593 _ => None,
594 };
595 if let Some(c) = column {
596 columns.push(Box::new(c));
597 }
598 }
599
600 let expanded_index = app.iam_state.groups.expanded_item.and_then(|idx| {
601 if idx >= scroll_offset && idx < scroll_offset + page_size {
602 Some(idx - scroll_offset)
603 } else {
604 None
605 }
606 });
607
608 let config = crate::ui::table::TableConfig {
609 items: page_groups,
610 selected_index: app.iam_state.groups.selected - scroll_offset,
611 expanded_index,
612 columns: &columns,
613 sort_column: "Group name",
614 sort_direction: SortDirection::Asc,
615 title: format!(" User groups ({}) ", app.iam_state.groups.items.len()),
616 area: chunks[2],
617 is_active: app.mode != Mode::ColumnSelector,
618 get_expanded_content: Some(Box::new(|group: &crate::iam::IamGroup| {
619 crate::ui::table::expanded_from_columns(&columns, group)
620 })),
621 };
622
623 crate::ui::table::render_table(frame, config);
624}
625
626pub fn render_role_list(frame: &mut Frame, app: &App, area: Rect) {
627 let chunks = vertical(
628 [
629 Constraint::Length(1), Constraint::Length(3), Constraint::Min(0), ],
633 area,
634 );
635
636 let desc = Paragraph::new("An IAM role is an identity you can create that has specific permissions with credentials that are valid for short durations. Roles can be assumed by entities that you trust.")
637 .style(Style::default().fg(Color::White));
638 frame.render_widget(desc, chunks[0]);
639
640 let page_size = app.iam_state.roles.page_size.value();
642 let filtered_count = crate::ui::iam::filtered_iam_roles(app).len();
643 let total_pages = if filtered_count == 0 {
644 1
645 } else {
646 filtered_count.div_ceil(page_size)
647 };
648 let current_page = if filtered_count == 0 {
649 0
650 } else {
651 app.iam_state.roles.selected / page_size
652 };
653 let pagination = render_pagination_text(current_page, total_pages);
654
655 crate::ui::filter::render_simple_filter(
656 frame,
657 chunks[1],
658 crate::ui::filter::SimpleFilterConfig {
659 filter_text: &app.iam_state.roles.filter,
660 placeholder: "Search",
661 pagination: &pagination,
662 mode: app.mode,
663 is_input_focused: app.iam_state.role_input_focus == InputFocus::Filter,
664 is_pagination_focused: app.iam_state.role_input_focus == InputFocus::Pagination,
665 },
666 );
667
668 let scroll_offset = app.iam_state.roles.scroll_offset;
670 let page_roles: Vec<_> = filtered_iam_roles(app)
671 .into_iter()
672 .skip(scroll_offset)
673 .take(page_size)
674 .collect();
675
676 use crate::iam::RoleColumn;
677 let mut columns: Vec<Box<dyn Column<IamRole>>> = vec![];
678 for col in &app.iam_role_visible_column_ids {
679 let column = match col.as_str() {
680 "Role name" => Some(RoleColumn::RoleName),
681 "Path" => Some(RoleColumn::Path),
682 "Description" => Some(RoleColumn::Description),
683 "Trusted entities" => Some(RoleColumn::TrustedEntities),
684 "Creation time" => Some(RoleColumn::CreationTime),
685 "ARN" => Some(RoleColumn::Arn),
686 "Max CLI/API session" => Some(RoleColumn::MaxSessionDuration),
687 "Last activity" => Some(RoleColumn::LastActivity),
688 _ => None,
689 };
690 if let Some(c) = column {
691 columns.push(Box::new(c));
692 }
693 }
694
695 let expanded_index = app.iam_state.roles.expanded_item.and_then(|idx| {
696 if idx >= scroll_offset && idx < scroll_offset + page_size {
697 Some(idx - scroll_offset)
698 } else {
699 None
700 }
701 });
702
703 let config = crate::ui::table::TableConfig {
704 items: page_roles,
705 selected_index: app.iam_state.roles.selected % page_size,
706 expanded_index,
707 columns: &columns,
708 sort_column: "Role name",
709 sort_direction: SortDirection::Asc,
710 title: format!(" Roles ({}) ", filtered_count),
711 area: chunks[2],
712 is_active: app.mode != Mode::ColumnSelector,
713 get_expanded_content: Some(Box::new(|role: &crate::iam::IamRole| {
714 crate::ui::table::expanded_from_columns(&columns, role)
715 })),
716 };
717
718 crate::ui::table::render_table(frame, config);
719}
720
721pub fn render_role_detail(frame: &mut Frame, app: &App, area: Rect) {
722 frame.render_widget(Clear, area);
723
724 let summary_height = if app.iam_state.current_role.is_some() {
726 block_height_for(5) } else {
728 0
729 };
730
731 let chunks = vertical(
732 [
733 Constraint::Length(1), Constraint::Length(summary_height), Constraint::Length(1), Constraint::Min(0), ],
738 area,
739 );
740
741 if let Some(role_name) = &app.iam_state.current_role {
743 let label = Paragraph::new(role_name.as_str()).style(
744 Style::default()
745 .fg(Color::Yellow)
746 .add_modifier(Modifier::BOLD),
747 );
748 frame.render_widget(label, chunks[0]);
749 }
750
751 if let Some(role_name) = &app.iam_state.current_role {
753 if let Some(role) = app
754 .iam_state
755 .roles
756 .items
757 .iter()
758 .find(|r| r.role_name == *role_name)
759 {
760 let formatted_duration = role
761 .max_session_duration
762 .map(|d| crate::ui::format_duration(d as u64))
763 .unwrap_or_default();
764
765 crate::ui::render_summary(
766 frame,
767 chunks[1],
768 " Summary ",
769 &[
770 ("ARN: ", role.arn.clone()),
771 ("Trusted entities: ", role.trusted_entities.clone()),
772 ("Max session duration: ", formatted_duration),
773 ("Created: ", role.creation_time.clone()),
774 ("Description: ", role.description.clone()),
775 ],
776 );
777 }
778 }
779
780 render_tabs(
782 frame,
783 chunks[2],
784 &RoleTab::ALL
785 .iter()
786 .map(|tab| (tab.name(), *tab))
787 .collect::<Vec<_>>(),
788 &app.iam_state.role_tab,
789 );
790
791 match app.iam_state.role_tab {
793 RoleTab::Permissions => {
794 render_permissions_section(
795 frame,
796 chunks[3],
797 "You can attach up to 10 managed policies.",
798 |f, area| render_policies_table(f, app, area),
799 );
800 }
801 RoleTab::TrustRelationships => {
802 let chunks_inner = vertical(
803 [
804 Constraint::Length(1),
805 Constraint::Length(1),
806 Constraint::Min(0),
807 ],
808 chunks[3],
809 );
810
811 frame.render_widget(
812 Paragraph::new("Trusted entities").style(Style::default().fg(Color::Cyan).bold()),
813 chunks_inner[0],
814 );
815
816 frame.render_widget(
817 Paragraph::new("Entities that can assume this role under specified conditions."),
818 chunks_inner[1],
819 );
820
821 render_json_highlighted(
822 frame,
823 chunks_inner[2],
824 &app.iam_state.trust_policy_document,
825 app.iam_state.trust_policy_scroll,
826 " Trust Policy ",
827 true,
828 );
829 }
830 RoleTab::Tags => {
831 render_tags_section(frame, chunks[3], |f, area| render_tags_table(f, app, area));
832 }
833 RoleTab::RevokeSessions => {
834 let chunks_inner = vertical(
835 [
836 Constraint::Length(1),
837 Constraint::Length(2),
838 Constraint::Length(1),
839 Constraint::Min(0),
840 ],
841 chunks[3],
842 );
843
844 frame.render_widget(
845 Paragraph::new("Revoke all active sessions")
846 .style(Style::default().fg(Color::Cyan).bold()),
847 chunks_inner[0],
848 );
849
850 frame.render_widget(
851 Paragraph::new(
852 "If you choose Revoke active sessions, IAM attaches an inline policy named AWSRevokeOlderSessions to this role. This policy denies access to all currently active sessions for this role. You can continue to create new sessions based on this role. If you need to undo this action later, you can remove the inline policy."
853 ).wrap(ratatui::widgets::Wrap { trim: true }),
854 chunks_inner[1],
855 );
856
857 frame.render_widget(
858 Paragraph::new("Here is an example of the AWSRevokeOlderSessions policy that is created after you choose Revoke active sessions:"),
859 chunks_inner[2],
860 );
861
862 let example_policy = r#"{
863 "Version": "2012-10-17",
864 "Statement": [
865 {
866 "Effect": "Deny",
867 "Action": [
868 "*"
869 ],
870 "Resource": [
871 "*"
872 ],
873 "Condition": {
874 "DateLessThan": {
875 "aws:TokenIssueTime": "[policy creation time]"
876 }
877 }
878 }
879 ]
880}"#;
881
882 render_json_highlighted(
883 frame,
884 chunks_inner[3],
885 example_policy,
886 app.iam_state.revoke_sessions_scroll,
887 " Example Policy ",
888 true,
889 );
890 }
891 RoleTab::LastAccessed => {
892 render_last_accessed_section(
893 frame,
894 chunks[3],
895 "Last accessed information shows the services that this role can access and when those services were last accessed. Review this data to remove unused permissions.",
896 "IAM reports activity for services and management actions. Learn more about action last accessed information. To see actions, choose the appropriate service name from the list.",
897 |f, area| render_last_accessed_table(f, app, area),
898 );
899 }
900 }
901}
902
903pub fn render_user_detail(frame: &mut Frame, app: &App, area: Rect) {
904 frame.render_widget(Clear, area);
905
906 let summary_height = if app.iam_state.current_user.is_some() {
908 block_height_for(5) } else {
910 0
911 };
912
913 let chunks = vertical(
914 [
915 Constraint::Length(1), Constraint::Length(summary_height), Constraint::Length(1), Constraint::Min(0), ],
920 area,
921 );
922
923 if let Some(user_name) = &app.iam_state.current_user {
925 let label = Paragraph::new(user_name.as_str()).style(
926 Style::default()
927 .fg(Color::Yellow)
928 .add_modifier(Modifier::BOLD),
929 );
930 frame.render_widget(label, chunks[0]);
931 }
932
933 if let Some(user_name) = &app.iam_state.current_user {
935 if let Some(user) = app
936 .iam_state
937 .users
938 .items
939 .iter()
940 .find(|u| u.user_name == *user_name)
941 {
942 let summary_block = Block::default()
943 .title(" Summary ")
944 .borders(Borders::ALL)
945 .border_type(BorderType::Rounded)
946 .border_style(active_border());
947
948 let summary_inner = summary_block.inner(chunks[1]);
949 frame.render_widget(summary_block, chunks[1]);
950
951 let summary_lines = vec![
952 labeled_field("ARN", &user.arn),
953 labeled_field("Console access", &user.console_access),
954 labeled_field("Access key", &user.access_key_id),
955 labeled_field("Created", &user.creation_time),
956 labeled_field("Last console sign-in", &user.console_last_sign_in),
957 ];
958
959 let summary_paragraph = Paragraph::new(summary_lines);
960 frame.render_widget(summary_paragraph, summary_inner);
961 }
962 }
963
964 render_tabs(
966 frame,
967 chunks[2],
968 &UserTab::ALL
969 .iter()
970 .map(|tab| (tab.name(), *tab))
971 .collect::<Vec<_>>(),
972 &app.iam_state.user_tab,
973 );
974
975 if app.iam_state.user_tab == UserTab::Permissions {
977 render_permissions_tab(frame, app, chunks[3]);
978 } else if app.iam_state.user_tab == UserTab::Groups {
979 render_user_groups_tab(frame, app, chunks[3]);
980 } else if app.iam_state.user_tab == UserTab::Tags {
981 render_tags_section(frame, chunks[3], |f, area| {
982 render_user_tags_table(f, app, area)
983 });
984 } else if app.iam_state.user_tab == UserTab::LastAccessed {
985 render_user_last_accessed_tab(frame, app, chunks[3]);
986 }
987}
988
989pub fn render_permissions_tab(frame: &mut Frame, app: &App, area: Rect) {
990 render_permissions_section(
991 frame,
992 area,
993 "Permissions are defined by policies attached to the user directly or through groups.",
994 |f, area| render_policies_table(f, app, area),
995 );
996}
997
998pub fn render_policy_view(frame: &mut Frame, app: &App, area: Rect) {
999 frame.render_widget(Clear, area);
1000
1001 let chunks = vertical([Constraint::Length(1), Constraint::Min(0)], area);
1002
1003 let policy_name = app.iam_state.current_policy.as_deref().unwrap_or("");
1004 frame.render_widget(
1005 Paragraph::new(policy_name).style(Style::default().fg(Color::Cyan).bold()),
1006 chunks[0],
1007 );
1008
1009 render_json_highlighted(
1010 frame,
1011 chunks[1],
1012 &app.iam_state.policy_document,
1013 app.iam_state.policy_scroll,
1014 " Policy Document ",
1015 true,
1016 );
1017}
1018
1019pub fn render_policies_table(frame: &mut Frame, app: &App, area: Rect) {
1020 let chunks = vertical(
1021 [
1022 Constraint::Length(3), Constraint::Min(0), ],
1025 area,
1026 );
1027
1028 let cursor = get_cursor(app.mode == Mode::FilterInput);
1030 let page_size = app.iam_state.policies.page_size.value();
1031 let filtered_policies: Vec<_> = app
1032 .iam_state
1033 .policies
1034 .items
1035 .iter()
1036 .filter(|p| {
1037 let matches_filter = if app.iam_state.policies.filter.is_empty() {
1038 true
1039 } else {
1040 p.policy_name
1041 .to_lowercase()
1042 .contains(&app.iam_state.policies.filter.to_lowercase())
1043 };
1044 let matches_type = if app.iam_state.policy_type_filter == "All types" {
1045 true
1046 } else {
1047 p.policy_type == app.iam_state.policy_type_filter
1048 };
1049 matches_filter && matches_type
1050 })
1051 .collect();
1052
1053 let filtered_count = filtered_policies.len();
1054 let total_pages = filtered_count.div_ceil(page_size);
1055 let current_page = app.iam_state.policies.selected / page_size;
1056 let pagination = render_pagination_text(current_page, total_pages);
1057 let dropdown = format!("Type: {}", app.iam_state.policy_type_filter);
1058
1059 let filter_width = (chunks[0].width as usize).saturating_sub(4);
1060 let right_content = format!("{} ⋮ {}", dropdown, pagination);
1061 let right_len = right_content.len();
1062 let available_space = filter_width.saturating_sub(right_len + 1);
1063
1064 let mut first_line_spans = vec![];
1065 if app.iam_state.policies.filter.is_empty() && app.mode != Mode::FilterInput {
1066 first_line_spans.push(Span::styled("Search", Style::default().fg(Color::DarkGray)));
1067 } else {
1068 let display_text = if app.iam_state.policies.filter.len() > available_space {
1069 format!(
1070 "...{}",
1071 &app.iam_state.policies.filter
1072 [app.iam_state.policies.filter.len() - available_space + 3..]
1073 )
1074 } else {
1075 app.iam_state.policies.filter.clone()
1076 };
1077 first_line_spans.push(Span::raw(display_text));
1078 }
1079 if app.mode == Mode::FilterInput {
1080 first_line_spans.push(Span::raw(cursor));
1081 }
1082
1083 first_line_spans.push(Span::raw(
1084 " ".repeat(
1085 available_space.saturating_sub(
1086 first_line_spans
1087 .iter()
1088 .map(|s| s.content.len())
1089 .sum::<usize>(),
1090 ),
1091 ),
1092 ));
1093 first_line_spans.push(Span::styled(
1094 right_content,
1095 Style::default().fg(Color::DarkGray),
1096 ));
1097
1098 let filter = filter_area(first_line_spans, app.mode == Mode::FilterInput);
1099 frame.render_widget(filter, chunks[0]);
1100
1101 let scroll_offset = app.iam_state.policies.scroll_offset;
1103 let page_policies: Vec<&crate::iam::Policy> = filtered_policies
1104 .into_iter()
1105 .skip(scroll_offset)
1106 .take(page_size)
1107 .collect();
1108
1109 use crate::iam::PolicyColumn;
1111 let mut columns: Vec<Box<dyn Column<Policy>>> = vec![];
1112 for col in &app.iam_policy_visible_column_ids {
1113 match col.as_str() {
1114 "Policy name" => columns.push(Box::new(PolicyColumn::PolicyName)),
1115 "Type" => columns.push(Box::new(PolicyColumn::Type)),
1116 "Attached via" => columns.push(Box::new(PolicyColumn::AttachedVia)),
1117 "Attached entities" => columns.push(Box::new(PolicyColumn::AttachedEntities)),
1118 "Description" => columns.push(Box::new(PolicyColumn::Description)),
1119 "Creation time" => columns.push(Box::new(PolicyColumn::CreationTime)),
1120 "Edited time" => columns.push(Box::new(PolicyColumn::EditedTime)),
1121 _ => {}
1122 }
1123 }
1124
1125 let expanded_index = app.iam_state.policies.expanded_item.and_then(|idx| {
1126 if idx >= scroll_offset && idx < scroll_offset + page_size {
1127 Some(idx - scroll_offset)
1128 } else {
1129 None
1130 }
1131 });
1132
1133 let config = crate::ui::table::TableConfig {
1134 items: page_policies,
1135 selected_index: app.iam_state.policies.selected - scroll_offset,
1136 expanded_index,
1137 columns: &columns,
1138 sort_column: "Policy name",
1139 sort_direction: SortDirection::Asc,
1140 title: format!(" Permissions policies ({}) ", filtered_count),
1141 area: chunks[1],
1142 is_active: app.mode != Mode::ColumnSelector,
1143 get_expanded_content: Some(Box::new(|policy: &crate::iam::Policy| {
1144 crate::ui::table::expanded_from_columns(&columns, policy)
1145 })),
1146 };
1147
1148 crate::ui::table::render_table(frame, config);
1149}
1150
1151pub fn render_tags_table(frame: &mut Frame, app: &App, area: Rect) {
1152 let chunks = vertical(
1153 [
1154 Constraint::Length(3), Constraint::Min(0), ],
1157 area,
1158 );
1159
1160 let cursor = get_cursor(app.mode == Mode::FilterInput);
1162 let page_size = app.iam_state.tags.page_size.value();
1163 let filtered_tags: Vec<_> = app
1164 .iam_state
1165 .tags
1166 .items
1167 .iter()
1168 .filter(|t| {
1169 if app.iam_state.tags.filter.is_empty() {
1170 true
1171 } else {
1172 t.key
1173 .to_lowercase()
1174 .contains(&app.iam_state.tags.filter.to_lowercase())
1175 || t.value
1176 .to_lowercase()
1177 .contains(&app.iam_state.tags.filter.to_lowercase())
1178 }
1179 })
1180 .collect();
1181
1182 let filtered_count = filtered_tags.len();
1183 let total_pages = filtered_count.div_ceil(page_size);
1184 let current_page = app.iam_state.tags.selected / page_size;
1185 let pagination = render_pagination_text(current_page, total_pages);
1186
1187 let filter_width = (chunks[0].width as usize).saturating_sub(4);
1188 let pagination_len = pagination.len();
1189 let available_space = filter_width.saturating_sub(pagination_len + 1);
1190
1191 let mut first_line_spans = vec![];
1192 if app.iam_state.tags.filter.is_empty() && app.mode != Mode::FilterInput {
1193 first_line_spans.push(Span::styled("Search", Style::default().fg(Color::DarkGray)));
1194 } else {
1195 first_line_spans.push(Span::raw(&app.iam_state.tags.filter));
1196 }
1197 if app.mode == Mode::FilterInput {
1198 first_line_spans.push(Span::raw(cursor));
1199 }
1200
1201 let content_len = if app.iam_state.tags.filter.is_empty() && app.mode != Mode::FilterInput {
1202 6
1203 } else {
1204 app.iam_state.tags.filter.len() + cursor.len()
1205 };
1206
1207 if content_len < available_space {
1208 first_line_spans.push(Span::raw(
1209 " ".repeat(available_space.saturating_sub(content_len)),
1210 ));
1211 }
1212 first_line_spans.push(Span::styled(
1213 pagination,
1214 Style::default().fg(Color::DarkGray),
1215 ));
1216
1217 let filter = filter_area(first_line_spans, app.mode == Mode::FilterInput);
1218 frame.render_widget(filter, chunks[0]);
1219
1220 let scroll_offset = app.iam_state.tags.scroll_offset;
1222 let page_tags: Vec<&crate::iam::RoleTag> = filtered_tags
1223 .into_iter()
1224 .skip(scroll_offset)
1225 .take(page_size)
1226 .collect();
1227
1228 use crate::iam::TagColumn;
1229 let columns: Vec<Box<dyn Column<RoleTag>>> =
1230 vec![Box::new(TagColumn::Key), Box::new(TagColumn::Value)];
1231
1232 let expanded_index = app.iam_state.tags.expanded_item.and_then(|idx| {
1233 if idx >= scroll_offset && idx < scroll_offset + page_size {
1234 Some(idx - scroll_offset)
1235 } else {
1236 None
1237 }
1238 });
1239
1240 let config = crate::ui::table::TableConfig {
1241 items: page_tags,
1242 selected_index: app.iam_state.tags.selected - scroll_offset,
1243 expanded_index,
1244 columns: &columns,
1245 sort_column: "",
1246 sort_direction: SortDirection::Asc,
1247 title: format!(" Tags ({}) ", app.iam_state.tags.items.len()),
1248 area: chunks[1],
1249 is_active: true,
1250 get_expanded_content: Some(Box::new(|tag: &crate::iam::RoleTag| {
1251 crate::ui::table::plain_expanded_content(format!(
1252 "Key: {}\nValue: {}",
1253 tag.key, tag.value
1254 ))
1255 })),
1256 };
1257
1258 crate::ui::table::render_table(frame, config);
1259}
1260
1261pub fn render_user_groups_tab(frame: &mut Frame, app: &App, area: Rect) {
1262 let chunks = vertical(
1263 [
1264 Constraint::Length(1), Constraint::Min(0), ],
1267 area,
1268 );
1269
1270 let desc = Paragraph::new(
1271 "A user group is a collection of IAM users. Use groups to specify permissions for a collection of users. A user can be a member of up to 10 groups at a time.",
1272 )
1273 .style(Style::default().fg(Color::White));
1274 frame.render_widget(desc, chunks[0]);
1275
1276 render_user_groups_table(frame, app, chunks[1]);
1277}
1278
1279pub fn render_user_groups_table(frame: &mut Frame, app: &App, area: Rect) {
1280 let chunks = vertical(
1281 [
1282 Constraint::Length(3), Constraint::Min(0), ],
1285 area,
1286 );
1287
1288 let cursor = get_cursor(app.mode == Mode::FilterInput);
1289 let page_size = app.iam_state.user_group_memberships.page_size.value();
1290 let filtered_groups: Vec<_> = app
1291 .iam_state
1292 .user_group_memberships
1293 .items
1294 .iter()
1295 .filter(|g| {
1296 if app.iam_state.user_group_memberships.filter.is_empty() {
1297 true
1298 } else {
1299 g.group_name
1300 .to_lowercase()
1301 .contains(&app.iam_state.user_group_memberships.filter.to_lowercase())
1302 }
1303 })
1304 .collect();
1305
1306 let filtered_count = filtered_groups.len();
1307 let total_pages = filtered_count.div_ceil(page_size);
1308 let current_page = app.iam_state.user_group_memberships.selected / page_size;
1309 let pagination = render_pagination_text(current_page, total_pages);
1310
1311 let filter_width = (chunks[0].width as usize).saturating_sub(4);
1312 let pagination_len = pagination.len();
1313 let available_space = filter_width.saturating_sub(pagination_len + 1);
1314
1315 let mut first_line_spans = vec![];
1316 if app.iam_state.user_group_memberships.filter.is_empty() && app.mode != Mode::FilterInput {
1317 first_line_spans.push(Span::styled("Search", Style::default().fg(Color::DarkGray)));
1318 } else {
1319 first_line_spans.push(Span::raw(&app.iam_state.user_group_memberships.filter));
1320 }
1321 if app.mode == Mode::FilterInput {
1322 first_line_spans.push(Span::raw(cursor));
1323 }
1324
1325 let content_len = if app.iam_state.user_group_memberships.filter.is_empty()
1326 && app.mode != Mode::FilterInput
1327 {
1328 6
1329 } else {
1330 app.iam_state.user_group_memberships.filter.len() + cursor.len()
1331 };
1332
1333 if content_len < available_space {
1334 first_line_spans.push(Span::raw(
1335 " ".repeat(available_space.saturating_sub(content_len)),
1336 ));
1337 }
1338 first_line_spans.push(Span::styled(
1339 pagination,
1340 Style::default().fg(Color::DarkGray),
1341 ));
1342
1343 let filter = filter_area(first_line_spans, app.mode == Mode::FilterInput);
1344 frame.render_widget(filter, chunks[0]);
1345
1346 let scroll_offset = app.iam_state.user_group_memberships.scroll_offset;
1347 let page_groups: Vec<&crate::iam::UserGroup> = filtered_groups
1348 .into_iter()
1349 .skip(scroll_offset)
1350 .take(page_size)
1351 .collect();
1352
1353 use crate::iam::UserGroupColumn;
1354 let columns: Vec<Box<dyn Column<UserGroup>>> = vec![
1355 Box::new(UserGroupColumn::GroupName),
1356 Box::new(UserGroupColumn::AttachedPolicies),
1357 ];
1358
1359 let expanded_index = app
1360 .iam_state
1361 .user_group_memberships
1362 .expanded_item
1363 .and_then(|idx| {
1364 if idx >= scroll_offset && idx < scroll_offset + page_size {
1365 Some(idx - scroll_offset)
1366 } else {
1367 None
1368 }
1369 });
1370
1371 let config = crate::ui::table::TableConfig {
1372 items: page_groups,
1373 selected_index: app.iam_state.user_group_memberships.selected - scroll_offset,
1374 expanded_index,
1375 columns: &columns,
1376 sort_column: "",
1377 sort_direction: SortDirection::Asc,
1378 title: format!(
1379 " User groups membership ({}) ",
1380 app.iam_state.user_group_memberships.items.len()
1381 ),
1382 area: chunks[1],
1383 is_active: true,
1384 get_expanded_content: Some(Box::new(|group: &crate::iam::UserGroup| {
1385 crate::ui::table::plain_expanded_content(format!(
1386 "Group: {}\nAttached policies: {}",
1387 group.group_name, group.attached_policies
1388 ))
1389 })),
1390 };
1391
1392 crate::ui::table::render_table(frame, config);
1393}
1394
1395pub fn render_user_last_accessed_tab(frame: &mut Frame, app: &App, area: Rect) {
1396 let chunks = vertical(
1397 [
1398 Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ],
1402 area,
1403 );
1404
1405 frame.render_widget(
1406 Paragraph::new(
1407 "Last accessed information shows the services that this user can access and when those services were last accessed. Review this data to remove unused permissions."
1408 ),
1409 chunks[0],
1410 );
1411
1412 frame.render_widget(
1413 Paragraph::new(
1414 "IAM reports activity for services and management actions. Learn more about action last accessed information. To see actions, choose the appropriate service name from the list."
1415 ),
1416 chunks[1],
1417 );
1418
1419 render_last_accessed_table(frame, app, chunks[2]);
1420}
1421
1422pub fn render_user_tags_table(frame: &mut Frame, app: &App, area: Rect) {
1423 let chunks = vertical(
1424 [
1425 Constraint::Length(3), Constraint::Min(0), ],
1428 area,
1429 );
1430
1431 let cursor = get_cursor(app.mode == Mode::FilterInput);
1433 let page_size = app.iam_state.user_tags.page_size.value();
1434 let filtered_tags: Vec<_> = app
1435 .iam_state
1436 .user_tags
1437 .items
1438 .iter()
1439 .filter(|t| {
1440 if app.iam_state.user_tags.filter.is_empty() {
1441 true
1442 } else {
1443 t.key
1444 .to_lowercase()
1445 .contains(&app.iam_state.user_tags.filter.to_lowercase())
1446 || t.value
1447 .to_lowercase()
1448 .contains(&app.iam_state.user_tags.filter.to_lowercase())
1449 }
1450 })
1451 .collect();
1452
1453 let filtered_count = filtered_tags.len();
1454 let total_pages = filtered_count.div_ceil(page_size);
1455 let current_page = app.iam_state.user_tags.selected / page_size;
1456 let pagination = render_pagination_text(current_page, total_pages);
1457
1458 let filter_width = (chunks[0].width as usize).saturating_sub(4);
1459 let pagination_len = pagination.len();
1460 let available_space = filter_width.saturating_sub(pagination_len + 1);
1461
1462 let mut first_line_spans = vec![];
1463 if app.iam_state.user_tags.filter.is_empty() && app.mode != Mode::FilterInput {
1464 first_line_spans.push(Span::styled("Search", Style::default().fg(Color::DarkGray)));
1465 } else {
1466 first_line_spans.push(Span::raw(&app.iam_state.user_tags.filter));
1467 }
1468 if app.mode == Mode::FilterInput {
1469 first_line_spans.push(Span::raw(cursor));
1470 }
1471
1472 let content_len = if app.iam_state.user_tags.filter.is_empty() && app.mode != Mode::FilterInput
1473 {
1474 6
1475 } else {
1476 app.iam_state.user_tags.filter.len() + cursor.len()
1477 };
1478
1479 if content_len < available_space {
1480 first_line_spans.push(Span::raw(
1481 " ".repeat(available_space.saturating_sub(content_len)),
1482 ));
1483 }
1484 first_line_spans.push(Span::styled(
1485 pagination,
1486 Style::default().fg(Color::DarkGray),
1487 ));
1488
1489 let filter = filter_area(first_line_spans, app.mode == Mode::FilterInput);
1490 frame.render_widget(filter, chunks[0]);
1491
1492 let scroll_offset = app.iam_state.user_tags.scroll_offset;
1494 let page_tags: Vec<&crate::iam::UserTag> = filtered_tags
1495 .into_iter()
1496 .skip(scroll_offset)
1497 .take(page_size)
1498 .collect();
1499
1500 use crate::iam::TagColumn;
1501 let columns: Vec<Box<dyn Column<UserTag>>> =
1502 vec![Box::new(TagColumn::Key), Box::new(TagColumn::Value)];
1503
1504 let expanded_index = app.iam_state.user_tags.expanded_item.and_then(|idx| {
1505 if idx >= scroll_offset && idx < scroll_offset + page_size {
1506 Some(idx - scroll_offset)
1507 } else {
1508 None
1509 }
1510 });
1511
1512 let config = crate::ui::table::TableConfig {
1513 items: page_tags,
1514 selected_index: app.iam_state.user_tags.selected - scroll_offset,
1515 expanded_index,
1516 columns: &columns,
1517 sort_column: "",
1518 sort_direction: SortDirection::Asc,
1519 title: format!(" Tags ({}) ", app.iam_state.user_tags.items.len()),
1520 area: chunks[1],
1521 is_active: true,
1522 get_expanded_content: Some(Box::new(|tag: &crate::iam::UserTag| {
1523 crate::ui::table::plain_expanded_content(format!(
1524 "Key: {}\nValue: {}",
1525 tag.key, tag.value
1526 ))
1527 })),
1528 };
1529
1530 crate::ui::table::render_table(frame, config);
1531}
1532
1533pub fn render_last_accessed_table(frame: &mut Frame, app: &App, area: Rect) {
1534 let chunks = vertical(
1535 [
1536 Constraint::Length(3), Constraint::Min(0), ],
1539 area,
1540 );
1541
1542 let cursor = get_cursor(app.mode == Mode::FilterInput);
1544 let page_size = app.iam_state.last_accessed_services.page_size.value();
1545 let filtered_services: Vec<_> = app
1546 .iam_state
1547 .last_accessed_services
1548 .items
1549 .iter()
1550 .filter(|s| {
1551 let matches_filter = if app.iam_state.last_accessed_filter.is_empty() {
1552 true
1553 } else {
1554 s.service
1555 .to_lowercase()
1556 .contains(&app.iam_state.last_accessed_filter.to_lowercase())
1557 };
1558 let matches_history = match app.iam_state.last_accessed_history_filter {
1559 AccessHistoryFilter::NoFilter => true,
1560 AccessHistoryFilter::ServicesAccessed => {
1561 !s.last_accessed.is_empty() && s.last_accessed != "Not accessed"
1562 }
1563 AccessHistoryFilter::ServicesNotAccessed => {
1564 s.last_accessed.is_empty() || s.last_accessed == "Not accessed"
1565 }
1566 };
1567 matches_filter && matches_history
1568 })
1569 .collect();
1570
1571 let filtered_count = filtered_services.len();
1572 let total_pages = filtered_count.div_ceil(page_size);
1573 let current_page = app.iam_state.last_accessed_services.selected / page_size;
1574 let pagination = render_pagination_text(current_page, total_pages);
1575 let dropdown = format!(
1576 "Filter by services access history: {}",
1577 app.iam_state.last_accessed_history_filter.name()
1578 );
1579
1580 let filter_width = (chunks[0].width as usize).saturating_sub(4);
1581 let right_content = format!("{} ⋮ {}", dropdown, pagination);
1582 let right_len = right_content.len();
1583 let available_space = filter_width.saturating_sub(right_len + 1);
1584
1585 let mut first_line_spans = vec![];
1586 if app.iam_state.last_accessed_filter.is_empty() && app.mode != Mode::FilterInput {
1587 first_line_spans.push(Span::styled("Search", Style::default().fg(Color::DarkGray)));
1588 } else {
1589 let display_text = if app.iam_state.last_accessed_filter.len() > available_space {
1590 format!(
1591 "...{}",
1592 &app.iam_state.last_accessed_filter
1593 [app.iam_state.last_accessed_filter.len() - available_space + 3..]
1594 )
1595 } else {
1596 app.iam_state.last_accessed_filter.clone()
1597 };
1598 first_line_spans.push(Span::raw(display_text));
1599 }
1600 if app.mode == Mode::FilterInput {
1601 first_line_spans.push(Span::raw(cursor));
1602 }
1603
1604 first_line_spans.push(Span::raw(
1605 " ".repeat(
1606 available_space.saturating_sub(
1607 first_line_spans
1608 .iter()
1609 .map(|s| s.content.len())
1610 .sum::<usize>(),
1611 ),
1612 ),
1613 ));
1614 first_line_spans.push(Span::styled(
1615 right_content,
1616 Style::default().fg(Color::DarkGray),
1617 ));
1618 let filter = filter_area(first_line_spans, app.mode == Mode::FilterInput);
1619 frame.render_widget(filter, chunks[0]);
1620
1621 let scroll_offset = app.iam_state.last_accessed_services.scroll_offset;
1623 let page_services: Vec<&crate::iam::LastAccessedService> = filtered_services
1624 .into_iter()
1625 .skip(scroll_offset)
1626 .take(page_size)
1627 .collect();
1628
1629 use crate::iam::LastAccessedServiceColumn;
1630 let columns: Vec<Box<dyn Column<LastAccessedService>>> = vec![
1631 Box::new(LastAccessedServiceColumn::Service),
1632 Box::new(LastAccessedServiceColumn::PoliciesGranting),
1633 Box::new(LastAccessedServiceColumn::LastAccessed),
1634 ];
1635
1636 let expanded_index = app
1637 .iam_state
1638 .last_accessed_services
1639 .expanded_item
1640 .and_then(|idx| {
1641 if idx >= scroll_offset && idx < scroll_offset + page_size {
1642 Some(idx - scroll_offset)
1643 } else {
1644 None
1645 }
1646 });
1647
1648 let config = crate::ui::table::TableConfig {
1649 items: page_services,
1650 selected_index: app
1651 .iam_state
1652 .last_accessed_services
1653 .selected
1654 .saturating_sub(scroll_offset),
1655 expanded_index,
1656 columns: &columns,
1657 sort_column: "Last accessed",
1658 sort_direction: SortDirection::Desc,
1659 title: format!(
1660 " Allowed services ({}) ",
1661 app.iam_state.last_accessed_services.items.len()
1662 ),
1663 area: chunks[1],
1664 is_active: true,
1665 get_expanded_content: Some(Box::new(|service: &crate::iam::LastAccessedService| {
1666 crate::ui::table::plain_expanded_content(format!(
1667 "Service: {}\nPolicies granting permissions: {}\nLast accessed: {}",
1668 service.service, service.policies_granting, service.last_accessed
1669 ))
1670 })),
1671 };
1672
1673 crate::ui::table::render_table(frame, config);
1674}
1675
1676pub fn filtered_iam_users(app: &App) -> Vec<&crate::iam::IamUser> {
1678 if app.iam_state.users.filter.is_empty() {
1679 app.iam_state.users.items.iter().collect()
1680 } else {
1681 app.iam_state
1682 .users
1683 .items
1684 .iter()
1685 .filter(|u| {
1686 u.user_name
1687 .to_lowercase()
1688 .contains(&app.iam_state.users.filter.to_lowercase())
1689 })
1690 .collect()
1691 }
1692}
1693
1694pub fn filtered_iam_roles(app: &App) -> Vec<&crate::iam::IamRole> {
1695 if app.iam_state.roles.filter.is_empty() {
1696 app.iam_state.roles.items.iter().collect()
1697 } else {
1698 app.iam_state
1699 .roles
1700 .items
1701 .iter()
1702 .filter(|r| {
1703 r.role_name
1704 .to_lowercase()
1705 .contains(&app.iam_state.roles.filter.to_lowercase())
1706 })
1707 .collect()
1708 }
1709}
1710
1711pub fn filtered_iam_policies(app: &App) -> Vec<&crate::iam::Policy> {
1712 app.iam_state
1713 .policies
1714 .items
1715 .iter()
1716 .filter(|p| {
1717 let matches_filter = if app.iam_state.policies.filter.is_empty() {
1718 true
1719 } else {
1720 p.policy_name
1721 .to_lowercase()
1722 .contains(&app.iam_state.policies.filter.to_lowercase())
1723 };
1724 let matches_type = if app.iam_state.policy_type_filter == "All types" {
1725 true
1726 } else {
1727 p.policy_type == app.iam_state.policy_type_filter
1728 };
1729 matches_filter && matches_type
1730 })
1731 .collect()
1732}
1733
1734pub fn filtered_tags(app: &App) -> Vec<&crate::iam::RoleTag> {
1735 if app.iam_state.tags.filter.is_empty() {
1736 app.iam_state.tags.items.iter().collect()
1737 } else {
1738 app.iam_state
1739 .tags
1740 .items
1741 .iter()
1742 .filter(|t| {
1743 t.key
1744 .to_lowercase()
1745 .contains(&app.iam_state.tags.filter.to_lowercase())
1746 || t.value
1747 .to_lowercase()
1748 .contains(&app.iam_state.tags.filter.to_lowercase())
1749 })
1750 .collect()
1751 }
1752}
1753
1754pub fn filtered_user_tags(app: &App) -> Vec<&crate::iam::UserTag> {
1755 if app.iam_state.user_tags.filter.is_empty() {
1756 app.iam_state.user_tags.items.iter().collect()
1757 } else {
1758 app.iam_state
1759 .user_tags
1760 .items
1761 .iter()
1762 .filter(|t| {
1763 t.key
1764 .to_lowercase()
1765 .contains(&app.iam_state.user_tags.filter.to_lowercase())
1766 || t.value
1767 .to_lowercase()
1768 .contains(&app.iam_state.user_tags.filter.to_lowercase())
1769 })
1770 .collect()
1771 }
1772}
1773
1774pub fn filtered_last_accessed(app: &App) -> Vec<&crate::iam::LastAccessedService> {
1775 app.iam_state
1776 .last_accessed_services
1777 .items
1778 .iter()
1779 .filter(|s| {
1780 let matches_filter = if app.iam_state.last_accessed_filter.is_empty() {
1781 true
1782 } else {
1783 s.service
1784 .to_lowercase()
1785 .contains(&app.iam_state.last_accessed_filter.to_lowercase())
1786 };
1787 let matches_history = match app.iam_state.last_accessed_history_filter {
1788 crate::ui::iam::AccessHistoryFilter::NoFilter => true,
1789 crate::ui::iam::AccessHistoryFilter::ServicesAccessed => {
1790 !s.last_accessed.is_empty() && s.last_accessed != "Not accessed"
1791 }
1792 crate::ui::iam::AccessHistoryFilter::ServicesNotAccessed => {
1793 s.last_accessed.is_empty() || s.last_accessed == "Not accessed"
1794 }
1795 };
1796 matches_filter && matches_history
1797 })
1798 .collect()
1799}
1800
1801pub async fn load_iam_users(app: &mut App) -> anyhow::Result<()> {
1802 let users = app
1803 .iam_client
1804 .list_users()
1805 .await
1806 .map_err(|e| anyhow::anyhow!(e))?;
1807
1808 let mut iam_users = Vec::new();
1809 for u in users {
1810 let user_name = u.user_name().to_string();
1811
1812 let has_console = app
1813 .iam_client
1814 .get_login_profile(&user_name)
1815 .await
1816 .unwrap_or(false);
1817 let access_key_count = app
1818 .iam_client
1819 .list_access_keys(&user_name)
1820 .await
1821 .unwrap_or(0);
1822 let creation_time = {
1823 let dt = u.create_date();
1824 let timestamp = dt.secs();
1825 let datetime = chrono::DateTime::from_timestamp(timestamp, 0).unwrap_or_default();
1826 datetime.format("%Y-%m-%d %H:%M:%S (UTC)").to_string()
1827 };
1828
1829 iam_users.push(crate::iam::IamUser {
1830 user_name,
1831 path: u.path().to_string(),
1832 groups: String::new(),
1833 last_activity: String::new(),
1834 mfa: String::new(),
1835 password_age: String::new(),
1836 console_last_sign_in: String::new(),
1837 access_key_id: access_key_count.to_string(),
1838 active_key_age: String::new(),
1839 access_key_last_used: String::new(),
1840 arn: u.arn().to_string(),
1841 creation_time,
1842 console_access: if has_console {
1843 "Enabled".to_string()
1844 } else {
1845 "Disabled".to_string()
1846 },
1847 signing_certs: String::new(),
1848 });
1849 }
1850
1851 app.iam_state.users.items = iam_users;
1852
1853 Ok(())
1854}
1855
1856pub async fn load_iam_roles(app: &mut App) -> anyhow::Result<()> {
1857 let roles = app
1858 .iam_client
1859 .list_roles()
1860 .await
1861 .map_err(|e| anyhow::anyhow!(e))?;
1862
1863 let roles: Vec<crate::iam::IamRole> = roles
1864 .into_iter()
1865 .map(|r| {
1866 let trusted_entities = r
1867 .assume_role_policy_document()
1868 .and_then(|doc| {
1869 let decoded = urlencoding::decode(doc).ok()?;
1870 let policy: serde_json::Value = serde_json::from_str(&decoded).ok()?;
1871 let statements = policy.get("Statement")?.as_array()?;
1872
1873 let mut entities = Vec::new();
1874 for stmt in statements {
1875 if let Some(principal) = stmt.get("Principal") {
1876 if let Some(service) = principal.get("Service") {
1877 if let Some(s) = service.as_str() {
1878 let clean = s.replace(".amazonaws.com", "");
1879 entities.push(format!("AWS Service: {}", clean));
1880 } else if let Some(arr) = service.as_array() {
1881 for s in arr {
1882 if let Some(s) = s.as_str() {
1883 let clean = s.replace(".amazonaws.com", "");
1884 entities.push(format!("AWS Service: {}", clean));
1885 }
1886 }
1887 }
1888 }
1889 if let Some(aws) = principal.get("AWS") {
1890 if let Some(a) = aws.as_str() {
1891 if a.starts_with("arn:aws:iam::") {
1892 if let Some(account) = a.split(':').nth(4) {
1893 entities.push(format!("Account: {}", account));
1894 }
1895 } else {
1896 entities.push(format!("Account: {}", a));
1897 }
1898 } else if let Some(arr) = aws.as_array() {
1899 for a in arr {
1900 if let Some(a) = a.as_str() {
1901 if a.starts_with("arn:aws:iam::") {
1902 if let Some(account) = a.split(':').nth(4) {
1903 entities.push(format!("Account: {}", account));
1904 }
1905 } else {
1906 entities.push(format!("Account: {}", a));
1907 }
1908 }
1909 }
1910 }
1911 }
1912 }
1913 }
1914 Some(entities.join(", "))
1915 })
1916 .unwrap_or_default();
1917
1918 let last_activity = r
1919 .role_last_used()
1920 .and_then(|last_used| {
1921 last_used.last_used_date().map(|dt| {
1922 let timestamp = dt.secs();
1923 let datetime =
1924 chrono::DateTime::from_timestamp(timestamp, 0).unwrap_or_default();
1925 datetime.format("%Y-%m-%d %H:%M:%S (UTC)").to_string()
1926 })
1927 })
1928 .or_else(|| {
1929 r.role_last_used().and_then(|last_used| {
1930 last_used
1931 .region()
1932 .map(|region| format!("Used in {}", region))
1933 })
1934 })
1935 .unwrap_or_else(|| "-".to_string());
1936
1937 crate::iam::IamRole {
1938 role_name: r.role_name().to_string(),
1939 path: r.path().to_string(),
1940 trusted_entities,
1941 last_activity,
1942 arn: r.arn().to_string(),
1943 creation_time: {
1944 let dt = r.create_date();
1945 let timestamp = dt.secs();
1946 let datetime =
1947 chrono::DateTime::from_timestamp(timestamp, 0).unwrap_or_default();
1948 datetime.format("%Y-%m-%d %H:%M:%S (UTC)").to_string()
1949 },
1950 description: r.description().unwrap_or("").to_string(),
1951 max_session_duration: r.max_session_duration(),
1952 }
1953 })
1954 .collect();
1955
1956 app.iam_state.roles.items = roles;
1957
1958 Ok(())
1959}
1960
1961pub async fn load_iam_user_groups(app: &mut App) -> anyhow::Result<()> {
1962 let groups = app
1963 .iam_client
1964 .list_groups()
1965 .await
1966 .map_err(|e| anyhow::anyhow!(e))?;
1967
1968 let mut iam_groups = Vec::new();
1969 for g in groups {
1970 let creation_time = {
1971 let dt = g.create_date();
1972 let timestamp = dt.secs();
1973 let datetime = chrono::DateTime::from_timestamp(timestamp, 0).unwrap_or_default();
1974 datetime.format("%Y-%m-%d %H:%M:%S (UTC)").to_string()
1975 };
1976
1977 let group_name = g.group_name().to_string();
1978 let user_count = app.iam_client.get_group(&group_name).await.unwrap_or(0);
1979
1980 iam_groups.push(crate::iam::IamGroup {
1981 group_name,
1982 path: g.path().to_string(),
1983 users: user_count.to_string(),
1984 permissions: "Defined".to_string(),
1985 creation_time,
1986 });
1987 }
1988
1989 app.iam_state.groups.items = iam_groups;
1990
1991 Ok(())
1992}
1993
1994#[cfg(test)]
1995mod tests {
1996 use super::*;
1997
1998 #[test]
1999 fn test_policy_input_focus_next() {
2000 assert_eq!(
2001 InputFocus::Filter.next(&POLICY_FILTER_CONTROLS),
2002 POLICY_TYPE_DROPDOWN
2003 );
2004 assert_eq!(
2005 POLICY_TYPE_DROPDOWN.next(&POLICY_FILTER_CONTROLS),
2006 InputFocus::Pagination
2007 );
2008 assert_eq!(
2009 InputFocus::Pagination.next(&POLICY_FILTER_CONTROLS),
2010 InputFocus::Filter
2011 );
2012 }
2013
2014 #[test]
2015 fn test_policy_input_focus_prev() {
2016 assert_eq!(
2017 InputFocus::Filter.prev(&POLICY_FILTER_CONTROLS),
2018 InputFocus::Pagination
2019 );
2020 assert_eq!(
2021 InputFocus::Pagination.prev(&POLICY_FILTER_CONTROLS),
2022 POLICY_TYPE_DROPDOWN
2023 );
2024 assert_eq!(
2025 POLICY_TYPE_DROPDOWN.prev(&POLICY_FILTER_CONTROLS),
2026 InputFocus::Filter
2027 );
2028 }
2029
2030 #[test]
2031 fn test_role_input_focus_next() {
2032 assert_eq!(
2033 InputFocus::Filter.next(&ROLE_FILTER_CONTROLS),
2034 InputFocus::Pagination
2035 );
2036 assert_eq!(
2037 InputFocus::Pagination.next(&ROLE_FILTER_CONTROLS),
2038 InputFocus::Filter
2039 );
2040 }
2041
2042 #[test]
2043 fn test_role_input_focus_prev() {
2044 assert_eq!(
2045 InputFocus::Filter.prev(&ROLE_FILTER_CONTROLS),
2046 InputFocus::Pagination
2047 );
2048 assert_eq!(
2049 InputFocus::Pagination.prev(&ROLE_FILTER_CONTROLS),
2050 InputFocus::Filter
2051 );
2052 }
2053}