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, filter_area, get_cursor, labeled_field, render_json_highlighted,
12 render_last_accessed_section, render_permissions_section, render_tabs, render_tags_section,
13 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 chunks = vertical(
308 [
309 Constraint::Length(1), Constraint::Length(5), Constraint::Length(1), Constraint::Min(0), ],
314 area,
315 );
316
317 if let Some(group_name) = &app.iam_state.current_group {
318 let label = Paragraph::new(group_name.as_str()).style(
319 Style::default()
320 .fg(Color::Yellow)
321 .add_modifier(Modifier::BOLD),
322 );
323 frame.render_widget(label, chunks[0]);
324
325 if let Some(group) = app
327 .iam_state
328 .groups
329 .items
330 .iter()
331 .find(|g| g.group_name == *group_name)
332 {
333 crate::ui::render_summary(
334 frame,
335 chunks[1],
336 " Summary ",
337 &[
338 ("User group name: ", group.group_name.clone()),
339 ("Creation time: ", group.creation_time.clone()),
340 (
341 "ARN: ",
342 format!(
343 "arn:aws:iam::{}:group/{}",
344 app.config.account_id, group.group_name
345 ),
346 ),
347 ],
348 );
349 }
350
351 let tabs: Vec<(&str, GroupTab)> =
353 GroupTab::ALL.iter().map(|tab| (tab.name(), *tab)).collect();
354 render_tabs(frame, chunks[2], &tabs, &app.iam_state.group_tab);
355
356 match app.iam_state.group_tab {
358 GroupTab::Users => {
359 render_group_users_tab(frame, app, chunks[3]);
360 }
361 GroupTab::Permissions => {
362 render_permissions_section(
363 frame,
364 chunks[3],
365 "You can attach up to 10 managed policies.",
366 |f, area| render_policies_table(f, app, area),
367 );
368 }
369 GroupTab::AccessAdvisor => {
370 render_group_access_advisor_tab(frame, app, chunks[3]);
371 }
372 }
373 }
374}
375
376pub fn render_group_users_tab(frame: &mut Frame, app: &App, area: Rect) {
377 let chunks = vertical(
378 [
379 Constraint::Length(1), Constraint::Min(0), ],
382 area,
383 );
384
385 frame.render_widget(
386 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."),
387 chunks[0],
388 );
389
390 render_group_users_table(frame, app, chunks[1]);
391}
392
393pub fn render_group_users_table(frame: &mut Frame, app: &App, area: Rect) {
394 let chunks = vertical(
395 [
396 Constraint::Length(3), Constraint::Min(0), ],
399 area,
400 );
401
402 let page_size = app.iam_state.group_users.page_size.value();
403 let filtered_users: Vec<_> = app
404 .iam_state
405 .group_users
406 .items
407 .iter()
408 .filter(|u| {
409 if app.iam_state.group_users.filter.is_empty() {
410 true
411 } else {
412 u.user_name
413 .to_lowercase()
414 .contains(&app.iam_state.group_users.filter.to_lowercase())
415 }
416 })
417 .collect();
418
419 let filtered_count = filtered_users.len();
420 let total_pages = filtered_count.div_ceil(page_size);
421 let current_page = app.iam_state.group_users.selected / page_size;
422 let pagination = render_pagination_text(current_page, total_pages);
423
424 crate::ui::filter::render_simple_filter(
425 frame,
426 chunks[0],
427 crate::ui::filter::SimpleFilterConfig {
428 filter_text: &app.iam_state.group_users.filter,
429 placeholder: "Search",
430 pagination: &pagination,
431 mode: app.mode,
432 is_input_focused: true,
433 is_pagination_focused: false,
434 },
435 );
436
437 let scroll_offset = app.iam_state.group_users.scroll_offset;
438 let page_users: Vec<&crate::iam::GroupUser> = filtered_users
439 .into_iter()
440 .skip(scroll_offset)
441 .take(page_size)
442 .collect();
443
444 use crate::iam::GroupUserColumn;
445 let columns: Vec<Box<dyn Column<GroupUser>>> = vec![
446 Box::new(GroupUserColumn::UserName),
447 Box::new(GroupUserColumn::Groups),
448 Box::new(GroupUserColumn::LastActivity),
449 Box::new(GroupUserColumn::CreationTime),
450 ];
451
452 let expanded_index = app.iam_state.group_users.expanded_item.and_then(|idx| {
453 if idx >= scroll_offset && idx < scroll_offset + page_size {
454 Some(idx - scroll_offset)
455 } else {
456 None
457 }
458 });
459
460 let config = crate::ui::table::TableConfig {
461 items: page_users,
462 selected_index: app.iam_state.group_users.selected - scroll_offset,
463 expanded_index,
464 columns: &columns,
465 sort_column: "User name",
466 sort_direction: SortDirection::Asc,
467 title: format!(" Users ({}) ", app.iam_state.group_users.items.len()),
468 area: chunks[1],
469 is_active: app.mode != Mode::ColumnSelector,
470 get_expanded_content: Some(Box::new(|user: &crate::iam::GroupUser| {
471 crate::ui::table::plain_expanded_content(format!(
472 "User name: {}\nGroups: {}\nLast activity: {}\nCreation time: {}",
473 user.user_name, user.groups, user.last_activity, user.creation_time
474 ))
475 })),
476 };
477
478 crate::ui::table::render_table(frame, config);
479}
480
481pub fn render_group_access_advisor_tab(frame: &mut Frame, app: &App, area: Rect) {
482 let chunks = vertical(
483 [
484 Constraint::Length(1), Constraint::Min(0), ],
487 area,
488 );
489
490 frame.render_widget(
491 Paragraph::new(
492 "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."
493 ),
494 chunks[0],
495 );
496
497 render_last_accessed_table(frame, app, chunks[1]);
498}
499
500pub fn render_group_list(frame: &mut Frame, app: &App, area: Rect) {
501 let chunks = vertical(
502 [
503 Constraint::Length(1), Constraint::Length(3), Constraint::Min(0), ],
507 area,
508 );
509
510 let desc = Paragraph::new("A user group is a collection of IAM users. Use groups to specify permissions for a collection of users.")
511 .style(Style::default().fg(Color::White));
512 frame.render_widget(desc, chunks[0]);
513
514 let cursor = get_cursor(app.mode == Mode::FilterInput);
515 let page_size = app.iam_state.groups.page_size.value();
516 let filtered_groups: Vec<_> = app
517 .iam_state
518 .groups
519 .items
520 .iter()
521 .filter(|g| {
522 if app.iam_state.groups.filter.is_empty() {
523 true
524 } else {
525 g.group_name
526 .to_lowercase()
527 .contains(&app.iam_state.groups.filter.to_lowercase())
528 }
529 })
530 .collect();
531
532 let filtered_count = filtered_groups.len();
533 let total_pages = filtered_count.div_ceil(page_size);
534 let current_page = app.iam_state.groups.selected / page_size;
535 let pagination = render_pagination_text(current_page, total_pages);
536
537 let filter_width = (chunks[1].width as usize).saturating_sub(4);
538 let pagination_len = pagination.len();
539 let available_space = filter_width.saturating_sub(pagination_len + 1);
540
541 let mut first_line_spans = vec![];
542 if app.iam_state.groups.filter.is_empty() && app.mode != Mode::FilterInput {
543 first_line_spans.push(Span::styled("Search", Style::default().fg(Color::DarkGray)));
544 } else {
545 first_line_spans.push(Span::raw(&app.iam_state.groups.filter));
546 }
547 if app.mode == Mode::FilterInput {
548 first_line_spans.push(Span::raw(cursor));
549 }
550
551 let content_len = if app.iam_state.groups.filter.is_empty() && app.mode != Mode::FilterInput {
552 6
553 } else {
554 app.iam_state.groups.filter.len() + cursor.len()
555 };
556
557 if content_len < available_space {
558 first_line_spans.push(Span::raw(
559 " ".repeat(available_space.saturating_sub(content_len)),
560 ));
561 }
562 first_line_spans.push(Span::styled(
563 pagination,
564 Style::default().fg(Color::DarkGray),
565 ));
566
567 let filter = filter_area(first_line_spans, app.mode == Mode::FilterInput);
568 frame.render_widget(filter, chunks[1]);
569
570 let scroll_offset = app.iam_state.groups.scroll_offset;
571 let page_groups: Vec<&crate::iam::IamGroup> = filtered_groups
572 .into_iter()
573 .skip(scroll_offset)
574 .take(page_size)
575 .collect();
576
577 use crate::iam::GroupColumn;
578 let mut columns: Vec<Box<dyn Column<IamGroup>>> = vec![];
579 for col_name in &app.iam_group_visible_column_ids {
580 let column = match col_name.as_str() {
581 "Group name" => Some(GroupColumn::GroupName),
582 "Path" => Some(GroupColumn::Path),
583 "Users" => Some(GroupColumn::Users),
584 "Permissions" => Some(GroupColumn::Permissions),
585 "Creation time" => Some(GroupColumn::CreationTime),
586 _ => None,
587 };
588 if let Some(c) = column {
589 columns.push(Box::new(c));
590 }
591 }
592
593 let expanded_index = app.iam_state.groups.expanded_item.and_then(|idx| {
594 if idx >= scroll_offset && idx < scroll_offset + page_size {
595 Some(idx - scroll_offset)
596 } else {
597 None
598 }
599 });
600
601 let config = crate::ui::table::TableConfig {
602 items: page_groups,
603 selected_index: app.iam_state.groups.selected - scroll_offset,
604 expanded_index,
605 columns: &columns,
606 sort_column: "Group name",
607 sort_direction: SortDirection::Asc,
608 title: format!(" User groups ({}) ", app.iam_state.groups.items.len()),
609 area: chunks[2],
610 is_active: app.mode != Mode::ColumnSelector,
611 get_expanded_content: Some(Box::new(|group: &crate::iam::IamGroup| {
612 crate::ui::table::expanded_from_columns(&columns, group)
613 })),
614 };
615
616 crate::ui::table::render_table(frame, config);
617}
618
619pub fn render_role_list(frame: &mut Frame, app: &App, area: Rect) {
620 let chunks = vertical(
621 [
622 Constraint::Length(1), Constraint::Length(3), Constraint::Min(0), ],
626 area,
627 );
628
629 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.")
630 .style(Style::default().fg(Color::White));
631 frame.render_widget(desc, chunks[0]);
632
633 let page_size = app.iam_state.roles.page_size.value();
635 let filtered_count = crate::ui::iam::filtered_iam_roles(app).len();
636 let total_pages = if filtered_count == 0 {
637 1
638 } else {
639 filtered_count.div_ceil(page_size)
640 };
641 let current_page = if filtered_count == 0 {
642 0
643 } else {
644 app.iam_state.roles.selected / page_size
645 };
646 let pagination = render_pagination_text(current_page, total_pages);
647
648 crate::ui::filter::render_simple_filter(
649 frame,
650 chunks[1],
651 crate::ui::filter::SimpleFilterConfig {
652 filter_text: &app.iam_state.roles.filter,
653 placeholder: "Search",
654 pagination: &pagination,
655 mode: app.mode,
656 is_input_focused: app.iam_state.role_input_focus == InputFocus::Filter,
657 is_pagination_focused: app.iam_state.role_input_focus == InputFocus::Pagination,
658 },
659 );
660
661 let scroll_offset = app.iam_state.roles.scroll_offset;
663 let page_roles: Vec<_> = filtered_iam_roles(app)
664 .into_iter()
665 .skip(scroll_offset)
666 .take(page_size)
667 .collect();
668
669 use crate::iam::RoleColumn;
670 let mut columns: Vec<Box<dyn Column<IamRole>>> = vec![];
671 for col in &app.iam_role_visible_column_ids {
672 let column = match col.as_str() {
673 "Role name" => Some(RoleColumn::RoleName),
674 "Path" => Some(RoleColumn::Path),
675 "Description" => Some(RoleColumn::Description),
676 "Trusted entities" => Some(RoleColumn::TrustedEntities),
677 "Creation time" => Some(RoleColumn::CreationTime),
678 "ARN" => Some(RoleColumn::Arn),
679 "Max CLI/API session" => Some(RoleColumn::MaxSessionDuration),
680 "Last activity" => Some(RoleColumn::LastActivity),
681 _ => None,
682 };
683 if let Some(c) = column {
684 columns.push(Box::new(c));
685 }
686 }
687
688 let expanded_index = app.iam_state.roles.expanded_item.and_then(|idx| {
689 if idx >= scroll_offset && idx < scroll_offset + page_size {
690 Some(idx - scroll_offset)
691 } else {
692 None
693 }
694 });
695
696 let config = crate::ui::table::TableConfig {
697 items: page_roles,
698 selected_index: app.iam_state.roles.selected % page_size,
699 expanded_index,
700 columns: &columns,
701 sort_column: "Role name",
702 sort_direction: SortDirection::Asc,
703 title: format!(" Roles ({}) ", filtered_count),
704 area: chunks[2],
705 is_active: app.mode != Mode::ColumnSelector,
706 get_expanded_content: Some(Box::new(|role: &crate::iam::IamRole| {
707 crate::ui::table::expanded_from_columns(&columns, role)
708 })),
709 };
710
711 crate::ui::table::render_table(frame, config);
712}
713
714pub fn render_role_detail(frame: &mut Frame, app: &App, area: Rect) {
715 frame.render_widget(Clear, area);
716
717 let chunks = vertical(
718 [
719 Constraint::Length(1), Constraint::Length(7), Constraint::Length(1), Constraint::Min(0), ],
724 area,
725 );
726
727 if let Some(role_name) = &app.iam_state.current_role {
729 let label = Paragraph::new(role_name.as_str()).style(
730 Style::default()
731 .fg(Color::Yellow)
732 .add_modifier(Modifier::BOLD),
733 );
734 frame.render_widget(label, chunks[0]);
735 }
736
737 if let Some(role_name) = &app.iam_state.current_role {
739 if let Some(role) = app
740 .iam_state
741 .roles
742 .items
743 .iter()
744 .find(|r| r.role_name == *role_name)
745 {
746 let formatted_duration = role
747 .max_session_duration
748 .map(|d| crate::ui::format_duration(d as u64))
749 .unwrap_or_default();
750
751 crate::ui::render_summary(
752 frame,
753 chunks[1],
754 " Summary ",
755 &[
756 ("ARN: ", role.arn.clone()),
757 ("Trusted entities: ", role.trusted_entities.clone()),
758 ("Max session duration: ", formatted_duration),
759 ("Created: ", role.creation_time.clone()),
760 ("Description: ", role.description.clone()),
761 ],
762 );
763 }
764 }
765
766 render_tabs(
768 frame,
769 chunks[2],
770 &RoleTab::ALL
771 .iter()
772 .map(|tab| (tab.name(), *tab))
773 .collect::<Vec<_>>(),
774 &app.iam_state.role_tab,
775 );
776
777 match app.iam_state.role_tab {
779 RoleTab::Permissions => {
780 render_permissions_section(
781 frame,
782 chunks[3],
783 "You can attach up to 10 managed policies.",
784 |f, area| render_policies_table(f, app, area),
785 );
786 }
787 RoleTab::TrustRelationships => {
788 let chunks_inner = vertical(
789 [
790 Constraint::Length(1),
791 Constraint::Length(1),
792 Constraint::Min(0),
793 ],
794 chunks[3],
795 );
796
797 frame.render_widget(
798 Paragraph::new("Trusted entities").style(Style::default().fg(Color::Cyan).bold()),
799 chunks_inner[0],
800 );
801
802 frame.render_widget(
803 Paragraph::new("Entities that can assume this role under specified conditions."),
804 chunks_inner[1],
805 );
806
807 render_json_highlighted(
808 frame,
809 chunks_inner[2],
810 &app.iam_state.trust_policy_document,
811 app.iam_state.trust_policy_scroll,
812 " Trust Policy ",
813 );
814 }
815 RoleTab::Tags => {
816 render_tags_section(frame, chunks[3], |f, area| render_tags_table(f, app, area));
817 }
818 RoleTab::RevokeSessions => {
819 let chunks_inner = vertical(
820 [
821 Constraint::Length(1),
822 Constraint::Length(2),
823 Constraint::Length(1),
824 Constraint::Min(0),
825 ],
826 chunks[3],
827 );
828
829 frame.render_widget(
830 Paragraph::new("Revoke all active sessions")
831 .style(Style::default().fg(Color::Cyan).bold()),
832 chunks_inner[0],
833 );
834
835 frame.render_widget(
836 Paragraph::new(
837 "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."
838 ).wrap(ratatui::widgets::Wrap { trim: true }),
839 chunks_inner[1],
840 );
841
842 frame.render_widget(
843 Paragraph::new("Here is an example of the AWSRevokeOlderSessions policy that is created after you choose Revoke active sessions:"),
844 chunks_inner[2],
845 );
846
847 let example_policy = r#"{
848 "Version": "2012-10-17",
849 "Statement": [
850 {
851 "Effect": "Deny",
852 "Action": [
853 "*"
854 ],
855 "Resource": [
856 "*"
857 ],
858 "Condition": {
859 "DateLessThan": {
860 "aws:TokenIssueTime": "[policy creation time]"
861 }
862 }
863 }
864 ]
865}"#;
866
867 render_json_highlighted(
868 frame,
869 chunks_inner[3],
870 example_policy,
871 app.iam_state.revoke_sessions_scroll,
872 " Example Policy ",
873 );
874 }
875 RoleTab::LastAccessed => {
876 render_last_accessed_section(
877 frame,
878 chunks[3],
879 "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.",
880 "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.",
881 |f, area| render_last_accessed_table(f, app, area),
882 );
883 }
884 }
885}
886
887pub fn render_user_detail(frame: &mut Frame, app: &App, area: Rect) {
888 frame.render_widget(Clear, area);
889
890 let chunks = vertical(
891 [
892 Constraint::Length(1), Constraint::Length(7), Constraint::Length(1), Constraint::Min(0), ],
897 area,
898 );
899
900 if let Some(user_name) = &app.iam_state.current_user {
902 let label = Paragraph::new(user_name.as_str()).style(
903 Style::default()
904 .fg(Color::Yellow)
905 .add_modifier(Modifier::BOLD),
906 );
907 frame.render_widget(label, chunks[0]);
908 }
909
910 if let Some(user_name) = &app.iam_state.current_user {
912 if let Some(user) = app
913 .iam_state
914 .users
915 .items
916 .iter()
917 .find(|u| u.user_name == *user_name)
918 {
919 let summary_block = Block::default()
920 .title(" Summary ")
921 .borders(Borders::ALL)
922 .border_type(BorderType::Rounded)
923 .border_style(active_border());
924
925 let summary_inner = summary_block.inner(chunks[1]);
926 frame.render_widget(summary_block, chunks[1]);
927
928 let summary_lines = vec![
929 labeled_field("ARN", &user.arn),
930 labeled_field("Console access", &user.console_access),
931 labeled_field("Access key", &user.access_key_id),
932 labeled_field("Created", &user.creation_time),
933 labeled_field("Last console sign-in", &user.console_last_sign_in),
934 ];
935
936 let summary_paragraph = Paragraph::new(summary_lines);
937 frame.render_widget(summary_paragraph, summary_inner);
938 }
939 }
940
941 render_tabs(
943 frame,
944 chunks[2],
945 &UserTab::ALL
946 .iter()
947 .map(|tab| (tab.name(), *tab))
948 .collect::<Vec<_>>(),
949 &app.iam_state.user_tab,
950 );
951
952 if app.iam_state.user_tab == UserTab::Permissions {
954 render_permissions_tab(frame, app, chunks[3]);
955 } else if app.iam_state.user_tab == UserTab::Groups {
956 render_user_groups_tab(frame, app, chunks[3]);
957 } else if app.iam_state.user_tab == UserTab::Tags {
958 render_tags_section(frame, chunks[3], |f, area| {
959 render_user_tags_table(f, app, area)
960 });
961 } else if app.iam_state.user_tab == UserTab::LastAccessed {
962 render_user_last_accessed_tab(frame, app, chunks[3]);
963 }
964}
965
966pub fn render_permissions_tab(frame: &mut Frame, app: &App, area: Rect) {
967 render_permissions_section(
968 frame,
969 area,
970 "Permissions are defined by policies attached to the user directly or through groups.",
971 |f, area| render_policies_table(f, app, area),
972 );
973}
974
975pub fn render_policy_view(frame: &mut Frame, app: &App, area: Rect) {
976 frame.render_widget(Clear, area);
977
978 let chunks = vertical([Constraint::Length(1), Constraint::Min(0)], area);
979
980 let policy_name = app.iam_state.current_policy.as_deref().unwrap_or("");
981 frame.render_widget(
982 Paragraph::new(policy_name).style(Style::default().fg(Color::Cyan).bold()),
983 chunks[0],
984 );
985
986 render_json_highlighted(
987 frame,
988 chunks[1],
989 &app.iam_state.policy_document,
990 app.iam_state.policy_scroll,
991 " Policy Document ",
992 );
993}
994
995pub fn render_policies_table(frame: &mut Frame, app: &App, area: Rect) {
996 let chunks = vertical(
997 [
998 Constraint::Length(3), Constraint::Min(0), ],
1001 area,
1002 );
1003
1004 let cursor = get_cursor(app.mode == Mode::FilterInput);
1006 let page_size = app.iam_state.policies.page_size.value();
1007 let filtered_policies: Vec<_> = app
1008 .iam_state
1009 .policies
1010 .items
1011 .iter()
1012 .filter(|p| {
1013 let matches_filter = if app.iam_state.policies.filter.is_empty() {
1014 true
1015 } else {
1016 p.policy_name
1017 .to_lowercase()
1018 .contains(&app.iam_state.policies.filter.to_lowercase())
1019 };
1020 let matches_type = if app.iam_state.policy_type_filter == "All types" {
1021 true
1022 } else {
1023 p.policy_type == app.iam_state.policy_type_filter
1024 };
1025 matches_filter && matches_type
1026 })
1027 .collect();
1028
1029 let filtered_count = filtered_policies.len();
1030 let total_pages = filtered_count.div_ceil(page_size);
1031 let current_page = app.iam_state.policies.selected / page_size;
1032 let pagination = render_pagination_text(current_page, total_pages);
1033 let dropdown = format!("Type: {}", app.iam_state.policy_type_filter);
1034
1035 let filter_width = (chunks[0].width as usize).saturating_sub(4);
1036 let right_content = format!("{} ⋮ {}", dropdown, pagination);
1037 let right_len = right_content.len();
1038 let available_space = filter_width.saturating_sub(right_len + 1);
1039
1040 let mut first_line_spans = vec![];
1041 if app.iam_state.policies.filter.is_empty() && app.mode != Mode::FilterInput {
1042 first_line_spans.push(Span::styled("Search", Style::default().fg(Color::DarkGray)));
1043 } else {
1044 let display_text = if app.iam_state.policies.filter.len() > available_space {
1045 format!(
1046 "...{}",
1047 &app.iam_state.policies.filter
1048 [app.iam_state.policies.filter.len() - available_space + 3..]
1049 )
1050 } else {
1051 app.iam_state.policies.filter.clone()
1052 };
1053 first_line_spans.push(Span::raw(display_text));
1054 }
1055 if app.mode == Mode::FilterInput {
1056 first_line_spans.push(Span::raw(cursor));
1057 }
1058
1059 first_line_spans.push(Span::raw(
1060 " ".repeat(
1061 available_space.saturating_sub(
1062 first_line_spans
1063 .iter()
1064 .map(|s| s.content.len())
1065 .sum::<usize>(),
1066 ),
1067 ),
1068 ));
1069 first_line_spans.push(Span::styled(
1070 right_content,
1071 Style::default().fg(Color::DarkGray),
1072 ));
1073
1074 let filter = filter_area(first_line_spans, app.mode == Mode::FilterInput);
1075 frame.render_widget(filter, chunks[0]);
1076
1077 let scroll_offset = app.iam_state.policies.scroll_offset;
1079 let page_policies: Vec<&crate::iam::Policy> = filtered_policies
1080 .into_iter()
1081 .skip(scroll_offset)
1082 .take(page_size)
1083 .collect();
1084
1085 use crate::iam::PolicyColumn;
1087 let mut columns: Vec<Box<dyn Column<Policy>>> = vec![];
1088 for col in &app.iam_policy_visible_column_ids {
1089 match col.as_str() {
1090 "Policy name" => columns.push(Box::new(PolicyColumn::PolicyName)),
1091 "Type" => columns.push(Box::new(PolicyColumn::Type)),
1092 "Attached via" => columns.push(Box::new(PolicyColumn::AttachedVia)),
1093 "Attached entities" => columns.push(Box::new(PolicyColumn::AttachedEntities)),
1094 "Description" => columns.push(Box::new(PolicyColumn::Description)),
1095 "Creation time" => columns.push(Box::new(PolicyColumn::CreationTime)),
1096 "Edited time" => columns.push(Box::new(PolicyColumn::EditedTime)),
1097 _ => {}
1098 }
1099 }
1100
1101 let expanded_index = app.iam_state.policies.expanded_item.and_then(|idx| {
1102 if idx >= scroll_offset && idx < scroll_offset + page_size {
1103 Some(idx - scroll_offset)
1104 } else {
1105 None
1106 }
1107 });
1108
1109 let config = crate::ui::table::TableConfig {
1110 items: page_policies,
1111 selected_index: app.iam_state.policies.selected - scroll_offset,
1112 expanded_index,
1113 columns: &columns,
1114 sort_column: "Policy name",
1115 sort_direction: SortDirection::Asc,
1116 title: format!(" Permissions policies ({}) ", filtered_count),
1117 area: chunks[1],
1118 is_active: app.mode != Mode::ColumnSelector,
1119 get_expanded_content: Some(Box::new(|policy: &crate::iam::Policy| {
1120 crate::ui::table::expanded_from_columns(&columns, policy)
1121 })),
1122 };
1123
1124 crate::ui::table::render_table(frame, config);
1125}
1126
1127pub fn render_tags_table(frame: &mut Frame, app: &App, area: Rect) {
1128 let chunks = vertical(
1129 [
1130 Constraint::Length(3), Constraint::Min(0), ],
1133 area,
1134 );
1135
1136 let cursor = get_cursor(app.mode == Mode::FilterInput);
1138 let page_size = app.iam_state.tags.page_size.value();
1139 let filtered_tags: Vec<_> = app
1140 .iam_state
1141 .tags
1142 .items
1143 .iter()
1144 .filter(|t| {
1145 if app.iam_state.tags.filter.is_empty() {
1146 true
1147 } else {
1148 t.key
1149 .to_lowercase()
1150 .contains(&app.iam_state.tags.filter.to_lowercase())
1151 || t.value
1152 .to_lowercase()
1153 .contains(&app.iam_state.tags.filter.to_lowercase())
1154 }
1155 })
1156 .collect();
1157
1158 let filtered_count = filtered_tags.len();
1159 let total_pages = filtered_count.div_ceil(page_size);
1160 let current_page = app.iam_state.tags.selected / page_size;
1161 let pagination = render_pagination_text(current_page, total_pages);
1162
1163 let filter_width = (chunks[0].width as usize).saturating_sub(4);
1164 let pagination_len = pagination.len();
1165 let available_space = filter_width.saturating_sub(pagination_len + 1);
1166
1167 let mut first_line_spans = vec![];
1168 if app.iam_state.tags.filter.is_empty() && app.mode != Mode::FilterInput {
1169 first_line_spans.push(Span::styled("Search", Style::default().fg(Color::DarkGray)));
1170 } else {
1171 first_line_spans.push(Span::raw(&app.iam_state.tags.filter));
1172 }
1173 if app.mode == Mode::FilterInput {
1174 first_line_spans.push(Span::raw(cursor));
1175 }
1176
1177 let content_len = if app.iam_state.tags.filter.is_empty() && app.mode != Mode::FilterInput {
1178 6
1179 } else {
1180 app.iam_state.tags.filter.len() + cursor.len()
1181 };
1182
1183 if content_len < available_space {
1184 first_line_spans.push(Span::raw(
1185 " ".repeat(available_space.saturating_sub(content_len)),
1186 ));
1187 }
1188 first_line_spans.push(Span::styled(
1189 pagination,
1190 Style::default().fg(Color::DarkGray),
1191 ));
1192
1193 let filter = filter_area(first_line_spans, app.mode == Mode::FilterInput);
1194 frame.render_widget(filter, chunks[0]);
1195
1196 let scroll_offset = app.iam_state.tags.scroll_offset;
1198 let page_tags: Vec<&crate::iam::RoleTag> = filtered_tags
1199 .into_iter()
1200 .skip(scroll_offset)
1201 .take(page_size)
1202 .collect();
1203
1204 use crate::iam::TagColumn;
1205 let columns: Vec<Box<dyn Column<RoleTag>>> =
1206 vec![Box::new(TagColumn::Key), Box::new(TagColumn::Value)];
1207
1208 let expanded_index = app.iam_state.tags.expanded_item.and_then(|idx| {
1209 if idx >= scroll_offset && idx < scroll_offset + page_size {
1210 Some(idx - scroll_offset)
1211 } else {
1212 None
1213 }
1214 });
1215
1216 let config = crate::ui::table::TableConfig {
1217 items: page_tags,
1218 selected_index: app.iam_state.tags.selected - scroll_offset,
1219 expanded_index,
1220 columns: &columns,
1221 sort_column: "",
1222 sort_direction: SortDirection::Asc,
1223 title: format!(" Tags ({}) ", app.iam_state.tags.items.len()),
1224 area: chunks[1],
1225 is_active: true,
1226 get_expanded_content: Some(Box::new(|tag: &crate::iam::RoleTag| {
1227 crate::ui::table::plain_expanded_content(format!(
1228 "Key: {}\nValue: {}",
1229 tag.key, tag.value
1230 ))
1231 })),
1232 };
1233
1234 crate::ui::table::render_table(frame, config);
1235}
1236
1237pub fn render_user_groups_tab(frame: &mut Frame, app: &App, area: Rect) {
1238 let chunks = vertical(
1239 [
1240 Constraint::Length(1), Constraint::Min(0), ],
1243 area,
1244 );
1245
1246 let desc = Paragraph::new(
1247 "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.",
1248 )
1249 .style(Style::default().fg(Color::White));
1250 frame.render_widget(desc, chunks[0]);
1251
1252 render_user_groups_table(frame, app, chunks[1]);
1253}
1254
1255pub fn render_user_groups_table(frame: &mut Frame, app: &App, area: Rect) {
1256 let chunks = vertical(
1257 [
1258 Constraint::Length(3), Constraint::Min(0), ],
1261 area,
1262 );
1263
1264 let cursor = get_cursor(app.mode == Mode::FilterInput);
1265 let page_size = app.iam_state.user_group_memberships.page_size.value();
1266 let filtered_groups: Vec<_> = app
1267 .iam_state
1268 .user_group_memberships
1269 .items
1270 .iter()
1271 .filter(|g| {
1272 if app.iam_state.user_group_memberships.filter.is_empty() {
1273 true
1274 } else {
1275 g.group_name
1276 .to_lowercase()
1277 .contains(&app.iam_state.user_group_memberships.filter.to_lowercase())
1278 }
1279 })
1280 .collect();
1281
1282 let filtered_count = filtered_groups.len();
1283 let total_pages = filtered_count.div_ceil(page_size);
1284 let current_page = app.iam_state.user_group_memberships.selected / page_size;
1285 let pagination = render_pagination_text(current_page, total_pages);
1286
1287 let filter_width = (chunks[0].width as usize).saturating_sub(4);
1288 let pagination_len = pagination.len();
1289 let available_space = filter_width.saturating_sub(pagination_len + 1);
1290
1291 let mut first_line_spans = vec![];
1292 if app.iam_state.user_group_memberships.filter.is_empty() && app.mode != Mode::FilterInput {
1293 first_line_spans.push(Span::styled("Search", Style::default().fg(Color::DarkGray)));
1294 } else {
1295 first_line_spans.push(Span::raw(&app.iam_state.user_group_memberships.filter));
1296 }
1297 if app.mode == Mode::FilterInput {
1298 first_line_spans.push(Span::raw(cursor));
1299 }
1300
1301 let content_len = if app.iam_state.user_group_memberships.filter.is_empty()
1302 && app.mode != Mode::FilterInput
1303 {
1304 6
1305 } else {
1306 app.iam_state.user_group_memberships.filter.len() + cursor.len()
1307 };
1308
1309 if content_len < available_space {
1310 first_line_spans.push(Span::raw(
1311 " ".repeat(available_space.saturating_sub(content_len)),
1312 ));
1313 }
1314 first_line_spans.push(Span::styled(
1315 pagination,
1316 Style::default().fg(Color::DarkGray),
1317 ));
1318
1319 let filter = filter_area(first_line_spans, app.mode == Mode::FilterInput);
1320 frame.render_widget(filter, chunks[0]);
1321
1322 let scroll_offset = app.iam_state.user_group_memberships.scroll_offset;
1323 let page_groups: Vec<&crate::iam::UserGroup> = filtered_groups
1324 .into_iter()
1325 .skip(scroll_offset)
1326 .take(page_size)
1327 .collect();
1328
1329 use crate::iam::UserGroupColumn;
1330 let columns: Vec<Box<dyn Column<UserGroup>>> = vec![
1331 Box::new(UserGroupColumn::GroupName),
1332 Box::new(UserGroupColumn::AttachedPolicies),
1333 ];
1334
1335 let expanded_index = app
1336 .iam_state
1337 .user_group_memberships
1338 .expanded_item
1339 .and_then(|idx| {
1340 if idx >= scroll_offset && idx < scroll_offset + page_size {
1341 Some(idx - scroll_offset)
1342 } else {
1343 None
1344 }
1345 });
1346
1347 let config = crate::ui::table::TableConfig {
1348 items: page_groups,
1349 selected_index: app.iam_state.user_group_memberships.selected - scroll_offset,
1350 expanded_index,
1351 columns: &columns,
1352 sort_column: "",
1353 sort_direction: SortDirection::Asc,
1354 title: format!(
1355 " User groups membership ({}) ",
1356 app.iam_state.user_group_memberships.items.len()
1357 ),
1358 area: chunks[1],
1359 is_active: true,
1360 get_expanded_content: Some(Box::new(|group: &crate::iam::UserGroup| {
1361 crate::ui::table::plain_expanded_content(format!(
1362 "Group: {}\nAttached policies: {}",
1363 group.group_name, group.attached_policies
1364 ))
1365 })),
1366 };
1367
1368 crate::ui::table::render_table(frame, config);
1369}
1370
1371pub fn render_user_last_accessed_tab(frame: &mut Frame, app: &App, area: Rect) {
1372 let chunks = vertical(
1373 [
1374 Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ],
1378 area,
1379 );
1380
1381 frame.render_widget(
1382 Paragraph::new(
1383 "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."
1384 ),
1385 chunks[0],
1386 );
1387
1388 frame.render_widget(
1389 Paragraph::new(
1390 "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."
1391 ),
1392 chunks[1],
1393 );
1394
1395 render_last_accessed_table(frame, app, chunks[2]);
1396}
1397
1398pub fn render_user_tags_table(frame: &mut Frame, app: &App, area: Rect) {
1399 let chunks = vertical(
1400 [
1401 Constraint::Length(3), Constraint::Min(0), ],
1404 area,
1405 );
1406
1407 let cursor = get_cursor(app.mode == Mode::FilterInput);
1409 let page_size = app.iam_state.user_tags.page_size.value();
1410 let filtered_tags: Vec<_> = app
1411 .iam_state
1412 .user_tags
1413 .items
1414 .iter()
1415 .filter(|t| {
1416 if app.iam_state.user_tags.filter.is_empty() {
1417 true
1418 } else {
1419 t.key
1420 .to_lowercase()
1421 .contains(&app.iam_state.user_tags.filter.to_lowercase())
1422 || t.value
1423 .to_lowercase()
1424 .contains(&app.iam_state.user_tags.filter.to_lowercase())
1425 }
1426 })
1427 .collect();
1428
1429 let filtered_count = filtered_tags.len();
1430 let total_pages = filtered_count.div_ceil(page_size);
1431 let current_page = app.iam_state.user_tags.selected / page_size;
1432 let pagination = render_pagination_text(current_page, total_pages);
1433
1434 let filter_width = (chunks[0].width as usize).saturating_sub(4);
1435 let pagination_len = pagination.len();
1436 let available_space = filter_width.saturating_sub(pagination_len + 1);
1437
1438 let mut first_line_spans = vec![];
1439 if app.iam_state.user_tags.filter.is_empty() && app.mode != Mode::FilterInput {
1440 first_line_spans.push(Span::styled("Search", Style::default().fg(Color::DarkGray)));
1441 } else {
1442 first_line_spans.push(Span::raw(&app.iam_state.user_tags.filter));
1443 }
1444 if app.mode == Mode::FilterInput {
1445 first_line_spans.push(Span::raw(cursor));
1446 }
1447
1448 let content_len = if app.iam_state.user_tags.filter.is_empty() && app.mode != Mode::FilterInput
1449 {
1450 6
1451 } else {
1452 app.iam_state.user_tags.filter.len() + cursor.len()
1453 };
1454
1455 if content_len < available_space {
1456 first_line_spans.push(Span::raw(
1457 " ".repeat(available_space.saturating_sub(content_len)),
1458 ));
1459 }
1460 first_line_spans.push(Span::styled(
1461 pagination,
1462 Style::default().fg(Color::DarkGray),
1463 ));
1464
1465 let filter = filter_area(first_line_spans, app.mode == Mode::FilterInput);
1466 frame.render_widget(filter, chunks[0]);
1467
1468 let scroll_offset = app.iam_state.user_tags.scroll_offset;
1470 let page_tags: Vec<&crate::iam::UserTag> = filtered_tags
1471 .into_iter()
1472 .skip(scroll_offset)
1473 .take(page_size)
1474 .collect();
1475
1476 use crate::iam::TagColumn;
1477 let columns: Vec<Box<dyn Column<UserTag>>> =
1478 vec![Box::new(TagColumn::Key), Box::new(TagColumn::Value)];
1479
1480 let expanded_index = app.iam_state.user_tags.expanded_item.and_then(|idx| {
1481 if idx >= scroll_offset && idx < scroll_offset + page_size {
1482 Some(idx - scroll_offset)
1483 } else {
1484 None
1485 }
1486 });
1487
1488 let config = crate::ui::table::TableConfig {
1489 items: page_tags,
1490 selected_index: app.iam_state.user_tags.selected - scroll_offset,
1491 expanded_index,
1492 columns: &columns,
1493 sort_column: "",
1494 sort_direction: SortDirection::Asc,
1495 title: format!(" Tags ({}) ", app.iam_state.user_tags.items.len()),
1496 area: chunks[1],
1497 is_active: true,
1498 get_expanded_content: Some(Box::new(|tag: &crate::iam::UserTag| {
1499 crate::ui::table::plain_expanded_content(format!(
1500 "Key: {}\nValue: {}",
1501 tag.key, tag.value
1502 ))
1503 })),
1504 };
1505
1506 crate::ui::table::render_table(frame, config);
1507}
1508
1509pub fn render_last_accessed_table(frame: &mut Frame, app: &App, area: Rect) {
1510 let chunks = vertical(
1511 [
1512 Constraint::Length(3), Constraint::Min(0), ],
1515 area,
1516 );
1517
1518 let cursor = get_cursor(app.mode == Mode::FilterInput);
1520 let page_size = app.iam_state.last_accessed_services.page_size.value();
1521 let filtered_services: Vec<_> = app
1522 .iam_state
1523 .last_accessed_services
1524 .items
1525 .iter()
1526 .filter(|s| {
1527 let matches_filter = if app.iam_state.last_accessed_filter.is_empty() {
1528 true
1529 } else {
1530 s.service
1531 .to_lowercase()
1532 .contains(&app.iam_state.last_accessed_filter.to_lowercase())
1533 };
1534 let matches_history = match app.iam_state.last_accessed_history_filter {
1535 AccessHistoryFilter::NoFilter => true,
1536 AccessHistoryFilter::ServicesAccessed => {
1537 !s.last_accessed.is_empty() && s.last_accessed != "Not accessed"
1538 }
1539 AccessHistoryFilter::ServicesNotAccessed => {
1540 s.last_accessed.is_empty() || s.last_accessed == "Not accessed"
1541 }
1542 };
1543 matches_filter && matches_history
1544 })
1545 .collect();
1546
1547 let filtered_count = filtered_services.len();
1548 let total_pages = filtered_count.div_ceil(page_size);
1549 let current_page = app.iam_state.last_accessed_services.selected / page_size;
1550 let pagination = render_pagination_text(current_page, total_pages);
1551 let dropdown = format!(
1552 "Filter by services access history: {}",
1553 app.iam_state.last_accessed_history_filter.name()
1554 );
1555
1556 let filter_width = (chunks[0].width as usize).saturating_sub(4);
1557 let right_content = format!("{} ⋮ {}", dropdown, pagination);
1558 let right_len = right_content.len();
1559 let available_space = filter_width.saturating_sub(right_len + 1);
1560
1561 let mut first_line_spans = vec![];
1562 if app.iam_state.last_accessed_filter.is_empty() && app.mode != Mode::FilterInput {
1563 first_line_spans.push(Span::styled("Search", Style::default().fg(Color::DarkGray)));
1564 } else {
1565 let display_text = if app.iam_state.last_accessed_filter.len() > available_space {
1566 format!(
1567 "...{}",
1568 &app.iam_state.last_accessed_filter
1569 [app.iam_state.last_accessed_filter.len() - available_space + 3..]
1570 )
1571 } else {
1572 app.iam_state.last_accessed_filter.clone()
1573 };
1574 first_line_spans.push(Span::raw(display_text));
1575 }
1576 if app.mode == Mode::FilterInput {
1577 first_line_spans.push(Span::raw(cursor));
1578 }
1579
1580 first_line_spans.push(Span::raw(
1581 " ".repeat(
1582 available_space.saturating_sub(
1583 first_line_spans
1584 .iter()
1585 .map(|s| s.content.len())
1586 .sum::<usize>(),
1587 ),
1588 ),
1589 ));
1590 first_line_spans.push(Span::styled(
1591 right_content,
1592 Style::default().fg(Color::DarkGray),
1593 ));
1594 let filter = filter_area(first_line_spans, app.mode == Mode::FilterInput);
1595 frame.render_widget(filter, chunks[0]);
1596
1597 let scroll_offset = app.iam_state.last_accessed_services.scroll_offset;
1599 let page_services: Vec<&crate::iam::LastAccessedService> = filtered_services
1600 .into_iter()
1601 .skip(scroll_offset)
1602 .take(page_size)
1603 .collect();
1604
1605 use crate::iam::LastAccessedServiceColumn;
1606 let columns: Vec<Box<dyn Column<LastAccessedService>>> = vec![
1607 Box::new(LastAccessedServiceColumn::Service),
1608 Box::new(LastAccessedServiceColumn::PoliciesGranting),
1609 Box::new(LastAccessedServiceColumn::LastAccessed),
1610 ];
1611
1612 let expanded_index = app
1613 .iam_state
1614 .last_accessed_services
1615 .expanded_item
1616 .and_then(|idx| {
1617 if idx >= scroll_offset && idx < scroll_offset + page_size {
1618 Some(idx - scroll_offset)
1619 } else {
1620 None
1621 }
1622 });
1623
1624 let config = crate::ui::table::TableConfig {
1625 items: page_services,
1626 selected_index: app
1627 .iam_state
1628 .last_accessed_services
1629 .selected
1630 .saturating_sub(scroll_offset),
1631 expanded_index,
1632 columns: &columns,
1633 sort_column: "Last accessed",
1634 sort_direction: SortDirection::Desc,
1635 title: format!(
1636 " Allowed services ({}) ",
1637 app.iam_state.last_accessed_services.items.len()
1638 ),
1639 area: chunks[1],
1640 is_active: true,
1641 get_expanded_content: Some(Box::new(|service: &crate::iam::LastAccessedService| {
1642 crate::ui::table::plain_expanded_content(format!(
1643 "Service: {}\nPolicies granting permissions: {}\nLast accessed: {}",
1644 service.service, service.policies_granting, service.last_accessed
1645 ))
1646 })),
1647 };
1648
1649 crate::ui::table::render_table(frame, config);
1650}
1651
1652pub fn filtered_iam_users(app: &App) -> Vec<&crate::iam::IamUser> {
1654 if app.iam_state.users.filter.is_empty() {
1655 app.iam_state.users.items.iter().collect()
1656 } else {
1657 app.iam_state
1658 .users
1659 .items
1660 .iter()
1661 .filter(|u| {
1662 u.user_name
1663 .to_lowercase()
1664 .contains(&app.iam_state.users.filter.to_lowercase())
1665 })
1666 .collect()
1667 }
1668}
1669
1670pub fn filtered_iam_roles(app: &App) -> Vec<&crate::iam::IamRole> {
1671 if app.iam_state.roles.filter.is_empty() {
1672 app.iam_state.roles.items.iter().collect()
1673 } else {
1674 app.iam_state
1675 .roles
1676 .items
1677 .iter()
1678 .filter(|r| {
1679 r.role_name
1680 .to_lowercase()
1681 .contains(&app.iam_state.roles.filter.to_lowercase())
1682 })
1683 .collect()
1684 }
1685}
1686
1687pub fn filtered_iam_policies(app: &App) -> Vec<&crate::iam::Policy> {
1688 app.iam_state
1689 .policies
1690 .items
1691 .iter()
1692 .filter(|p| {
1693 let matches_filter = if app.iam_state.policies.filter.is_empty() {
1694 true
1695 } else {
1696 p.policy_name
1697 .to_lowercase()
1698 .contains(&app.iam_state.policies.filter.to_lowercase())
1699 };
1700 let matches_type = if app.iam_state.policy_type_filter == "All types" {
1701 true
1702 } else {
1703 p.policy_type == app.iam_state.policy_type_filter
1704 };
1705 matches_filter && matches_type
1706 })
1707 .collect()
1708}
1709
1710pub fn filtered_tags(app: &App) -> Vec<&crate::iam::RoleTag> {
1711 if app.iam_state.tags.filter.is_empty() {
1712 app.iam_state.tags.items.iter().collect()
1713 } else {
1714 app.iam_state
1715 .tags
1716 .items
1717 .iter()
1718 .filter(|t| {
1719 t.key
1720 .to_lowercase()
1721 .contains(&app.iam_state.tags.filter.to_lowercase())
1722 || t.value
1723 .to_lowercase()
1724 .contains(&app.iam_state.tags.filter.to_lowercase())
1725 })
1726 .collect()
1727 }
1728}
1729
1730pub fn filtered_user_tags(app: &App) -> Vec<&crate::iam::UserTag> {
1731 if app.iam_state.user_tags.filter.is_empty() {
1732 app.iam_state.user_tags.items.iter().collect()
1733 } else {
1734 app.iam_state
1735 .user_tags
1736 .items
1737 .iter()
1738 .filter(|t| {
1739 t.key
1740 .to_lowercase()
1741 .contains(&app.iam_state.user_tags.filter.to_lowercase())
1742 || t.value
1743 .to_lowercase()
1744 .contains(&app.iam_state.user_tags.filter.to_lowercase())
1745 })
1746 .collect()
1747 }
1748}
1749
1750pub fn filtered_last_accessed(app: &App) -> Vec<&crate::iam::LastAccessedService> {
1751 app.iam_state
1752 .last_accessed_services
1753 .items
1754 .iter()
1755 .filter(|s| {
1756 let matches_filter = if app.iam_state.last_accessed_filter.is_empty() {
1757 true
1758 } else {
1759 s.service
1760 .to_lowercase()
1761 .contains(&app.iam_state.last_accessed_filter.to_lowercase())
1762 };
1763 let matches_history = match app.iam_state.last_accessed_history_filter {
1764 crate::ui::iam::AccessHistoryFilter::NoFilter => true,
1765 crate::ui::iam::AccessHistoryFilter::ServicesAccessed => {
1766 !s.last_accessed.is_empty() && s.last_accessed != "Not accessed"
1767 }
1768 crate::ui::iam::AccessHistoryFilter::ServicesNotAccessed => {
1769 s.last_accessed.is_empty() || s.last_accessed == "Not accessed"
1770 }
1771 };
1772 matches_filter && matches_history
1773 })
1774 .collect()
1775}
1776
1777pub async fn load_iam_users(app: &mut App) -> anyhow::Result<()> {
1778 let users = app
1779 .iam_client
1780 .list_users()
1781 .await
1782 .map_err(|e| anyhow::anyhow!(e))?;
1783
1784 let mut iam_users = Vec::new();
1785 for u in users {
1786 let user_name = u.user_name().to_string();
1787
1788 let has_console = app
1789 .iam_client
1790 .get_login_profile(&user_name)
1791 .await
1792 .unwrap_or(false);
1793 let access_key_count = app
1794 .iam_client
1795 .list_access_keys(&user_name)
1796 .await
1797 .unwrap_or(0);
1798 let creation_time = {
1799 let dt = u.create_date();
1800 let timestamp = dt.secs();
1801 let datetime = chrono::DateTime::from_timestamp(timestamp, 0).unwrap_or_default();
1802 datetime.format("%Y-%m-%d %H:%M:%S (UTC)").to_string()
1803 };
1804
1805 iam_users.push(crate::iam::IamUser {
1806 user_name,
1807 path: u.path().to_string(),
1808 groups: String::new(),
1809 last_activity: String::new(),
1810 mfa: String::new(),
1811 password_age: String::new(),
1812 console_last_sign_in: String::new(),
1813 access_key_id: access_key_count.to_string(),
1814 active_key_age: String::new(),
1815 access_key_last_used: String::new(),
1816 arn: u.arn().to_string(),
1817 creation_time,
1818 console_access: if has_console {
1819 "Enabled".to_string()
1820 } else {
1821 "Disabled".to_string()
1822 },
1823 signing_certs: String::new(),
1824 });
1825 }
1826
1827 app.iam_state.users.items = iam_users;
1828
1829 Ok(())
1830}
1831
1832pub async fn load_iam_roles(app: &mut App) -> anyhow::Result<()> {
1833 let roles = app
1834 .iam_client
1835 .list_roles()
1836 .await
1837 .map_err(|e| anyhow::anyhow!(e))?;
1838
1839 let roles: Vec<crate::iam::IamRole> = roles
1840 .into_iter()
1841 .map(|r| {
1842 let trusted_entities = r
1843 .assume_role_policy_document()
1844 .and_then(|doc| {
1845 let decoded = urlencoding::decode(doc).ok()?;
1846 let policy: serde_json::Value = serde_json::from_str(&decoded).ok()?;
1847 let statements = policy.get("Statement")?.as_array()?;
1848
1849 let mut entities = Vec::new();
1850 for stmt in statements {
1851 if let Some(principal) = stmt.get("Principal") {
1852 if let Some(service) = principal.get("Service") {
1853 if let Some(s) = service.as_str() {
1854 let clean = s.replace(".amazonaws.com", "");
1855 entities.push(format!("AWS Service: {}", clean));
1856 } else if let Some(arr) = service.as_array() {
1857 for s in arr {
1858 if let Some(s) = s.as_str() {
1859 let clean = s.replace(".amazonaws.com", "");
1860 entities.push(format!("AWS Service: {}", clean));
1861 }
1862 }
1863 }
1864 }
1865 if let Some(aws) = principal.get("AWS") {
1866 if let Some(a) = aws.as_str() {
1867 if a.starts_with("arn:aws:iam::") {
1868 if let Some(account) = a.split(':').nth(4) {
1869 entities.push(format!("Account: {}", account));
1870 }
1871 } else {
1872 entities.push(format!("Account: {}", a));
1873 }
1874 } else if let Some(arr) = aws.as_array() {
1875 for a in arr {
1876 if let Some(a) = a.as_str() {
1877 if a.starts_with("arn:aws:iam::") {
1878 if let Some(account) = a.split(':').nth(4) {
1879 entities.push(format!("Account: {}", account));
1880 }
1881 } else {
1882 entities.push(format!("Account: {}", a));
1883 }
1884 }
1885 }
1886 }
1887 }
1888 }
1889 }
1890 Some(entities.join(", "))
1891 })
1892 .unwrap_or_default();
1893
1894 let last_activity = r
1895 .role_last_used()
1896 .and_then(|last_used| {
1897 last_used.last_used_date().map(|dt| {
1898 let timestamp = dt.secs();
1899 let datetime =
1900 chrono::DateTime::from_timestamp(timestamp, 0).unwrap_or_default();
1901 datetime.format("%Y-%m-%d %H:%M:%S (UTC)").to_string()
1902 })
1903 })
1904 .or_else(|| {
1905 r.role_last_used().and_then(|last_used| {
1906 last_used
1907 .region()
1908 .map(|region| format!("Used in {}", region))
1909 })
1910 })
1911 .unwrap_or_else(|| "-".to_string());
1912
1913 crate::iam::IamRole {
1914 role_name: r.role_name().to_string(),
1915 path: r.path().to_string(),
1916 trusted_entities,
1917 last_activity,
1918 arn: r.arn().to_string(),
1919 creation_time: {
1920 let dt = r.create_date();
1921 let timestamp = dt.secs();
1922 let datetime =
1923 chrono::DateTime::from_timestamp(timestamp, 0).unwrap_or_default();
1924 datetime.format("%Y-%m-%d %H:%M:%S (UTC)").to_string()
1925 },
1926 description: r.description().unwrap_or("").to_string(),
1927 max_session_duration: r.max_session_duration(),
1928 }
1929 })
1930 .collect();
1931
1932 app.iam_state.roles.items = roles;
1933
1934 Ok(())
1935}
1936
1937pub async fn load_iam_user_groups(app: &mut App) -> anyhow::Result<()> {
1938 let groups = app
1939 .iam_client
1940 .list_groups()
1941 .await
1942 .map_err(|e| anyhow::anyhow!(e))?;
1943
1944 let mut iam_groups = Vec::new();
1945 for g in groups {
1946 let creation_time = {
1947 let dt = g.create_date();
1948 let timestamp = dt.secs();
1949 let datetime = chrono::DateTime::from_timestamp(timestamp, 0).unwrap_or_default();
1950 datetime.format("%Y-%m-%d %H:%M:%S (UTC)").to_string()
1951 };
1952
1953 let group_name = g.group_name().to_string();
1954 let user_count = app.iam_client.get_group(&group_name).await.unwrap_or(0);
1955
1956 iam_groups.push(crate::iam::IamGroup {
1957 group_name,
1958 path: g.path().to_string(),
1959 users: user_count.to_string(),
1960 permissions: "Defined".to_string(),
1961 creation_time,
1962 });
1963 }
1964
1965 app.iam_state.groups.items = iam_groups;
1966
1967 Ok(())
1968}
1969
1970#[cfg(test)]
1971mod tests {
1972 use super::*;
1973
1974 #[test]
1975 fn test_policy_input_focus_next() {
1976 assert_eq!(
1977 InputFocus::Filter.next(&POLICY_FILTER_CONTROLS),
1978 POLICY_TYPE_DROPDOWN
1979 );
1980 assert_eq!(
1981 POLICY_TYPE_DROPDOWN.next(&POLICY_FILTER_CONTROLS),
1982 InputFocus::Pagination
1983 );
1984 assert_eq!(
1985 InputFocus::Pagination.next(&POLICY_FILTER_CONTROLS),
1986 InputFocus::Filter
1987 );
1988 }
1989
1990 #[test]
1991 fn test_policy_input_focus_prev() {
1992 assert_eq!(
1993 InputFocus::Filter.prev(&POLICY_FILTER_CONTROLS),
1994 InputFocus::Pagination
1995 );
1996 assert_eq!(
1997 InputFocus::Pagination.prev(&POLICY_FILTER_CONTROLS),
1998 POLICY_TYPE_DROPDOWN
1999 );
2000 assert_eq!(
2001 POLICY_TYPE_DROPDOWN.prev(&POLICY_FILTER_CONTROLS),
2002 InputFocus::Filter
2003 );
2004 }
2005
2006 #[test]
2007 fn test_role_input_focus_next() {
2008 assert_eq!(
2009 InputFocus::Filter.next(&ROLE_FILTER_CONTROLS),
2010 InputFocus::Pagination
2011 );
2012 assert_eq!(
2013 InputFocus::Pagination.next(&ROLE_FILTER_CONTROLS),
2014 InputFocus::Filter
2015 );
2016 }
2017
2018 #[test]
2019 fn test_role_input_focus_prev() {
2020 assert_eq!(
2021 InputFocus::Filter.prev(&ROLE_FILTER_CONTROLS),
2022 InputFocus::Pagination
2023 );
2024 assert_eq!(
2025 InputFocus::Pagination.prev(&ROLE_FILTER_CONTROLS),
2026 InputFocus::Filter
2027 );
2028 }
2029}