1use crate::app::{App, ViewMode};
2use crate::common::{
3 filter_by_field, filter_by_fields, format_duration_seconds, render_pagination_text, CyclicEnum,
4 InputFocus, SortDirection,
5};
6use crate::iam::{
7 GroupColumn, GroupUser, GroupUserColumn, IamGroup, IamRole, IamUser, LastAccessedService,
8 Policy, PolicyColumn, RoleColumn, RoleTag, UserColumn, UserGroup, UserTag,
9};
10use crate::keymap::Mode;
11use crate::table::TableState;
12use crate::ui::filter::{render_simple_filter, SimpleFilterConfig};
13use crate::ui::table::{
14 expanded_from_columns, plain_expanded_content, render_table, Column, TableConfig,
15};
16use crate::ui::{
17 block_height_for, calculate_dynamic_height, format_title, labeled_field,
18 render_fields_with_dynamic_columns, render_json_highlighted, render_last_accessed_section,
19 render_permissions_section, render_search_filter, render_summary, render_tabs,
20 render_tags_section, titled_block, vertical,
21};
22use ratatui::{prelude::*, widgets::*};
23
24pub const POLICY_TYPE_DROPDOWN: InputFocus = InputFocus::Dropdown("PolicyType");
25pub const HISTORY_FILTER: InputFocus = InputFocus::Dropdown("HistoryFilter");
26pub const POLICY_FILTER_CONTROLS: [InputFocus; 3] = [
27 InputFocus::Filter,
28 POLICY_TYPE_DROPDOWN,
29 InputFocus::Pagination,
30];
31
32pub const ROLE_FILTER_CONTROLS: [InputFocus; 2] = [InputFocus::Filter, InputFocus::Pagination];
33
34pub const USER_SIMPLE_FILTER_CONTROLS: [InputFocus; 2] =
35 [InputFocus::Filter, InputFocus::Pagination];
36
37pub const USER_LAST_ACCESSED_FILTER_CONTROLS: [InputFocus; 3] =
38 [InputFocus::Filter, HISTORY_FILTER, InputFocus::Pagination];
39
40pub const GROUP_FILTER_CONTROLS: [InputFocus; 2] = [InputFocus::Filter, InputFocus::Pagination];
41
42#[derive(Debug, Clone, Copy, PartialEq)]
43pub enum UserTab {
44 Permissions,
45 Groups,
46 Tags,
47 SecurityCredentials,
48 LastAccessed,
49}
50
51impl CyclicEnum for UserTab {
52 const ALL: &'static [Self] = &[
53 Self::Permissions,
54 Self::Groups,
55 Self::Tags,
56 Self::SecurityCredentials,
57 Self::LastAccessed,
58 ];
59}
60
61impl UserTab {
62 pub fn name(&self) -> &'static str {
63 match self {
64 UserTab::Permissions => "Permissions",
65 UserTab::Groups => "Groups",
66 UserTab::Tags => "Tags",
67 UserTab::SecurityCredentials => "Security Credentials",
68 UserTab::LastAccessed => "Last Accessed",
69 }
70 }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq)]
74pub enum RoleTab {
75 Permissions,
76 TrustRelationships,
77 Tags,
78 LastAccessed,
79 RevokeSessions,
80}
81
82impl CyclicEnum for RoleTab {
83 const ALL: &'static [Self] = &[
84 Self::Permissions,
85 Self::TrustRelationships,
86 Self::Tags,
87 Self::LastAccessed,
88 Self::RevokeSessions,
89 ];
90}
91
92impl RoleTab {
93 pub fn name(&self) -> &'static str {
94 match self {
95 RoleTab::Permissions => "Permissions",
96 RoleTab::TrustRelationships => "Trust relationships",
97 RoleTab::Tags => "Tags",
98 RoleTab::LastAccessed => "Last Accessed",
99 RoleTab::RevokeSessions => "Revoke sessions",
100 }
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq)]
105pub enum GroupTab {
106 Users,
107 Permissions,
108 AccessAdvisor,
109}
110
111impl CyclicEnum for GroupTab {
112 const ALL: &'static [Self] = &[Self::Users, Self::Permissions, Self::AccessAdvisor];
113}
114
115impl GroupTab {
116 pub fn name(&self) -> &'static str {
117 match self {
118 GroupTab::Users => "Users",
119 GroupTab::Permissions => "Permissions",
120 GroupTab::AccessAdvisor => "Access Advisor",
121 }
122 }
123}
124
125#[derive(Debug, Clone, Copy, PartialEq)]
126pub enum AccessHistoryFilter {
127 NoFilter,
128 ServicesAccessed,
129 ServicesNotAccessed,
130}
131
132impl CyclicEnum for AccessHistoryFilter {
133 const ALL: &'static [Self] = &[
134 Self::NoFilter,
135 Self::ServicesAccessed,
136 Self::ServicesNotAccessed,
137 ];
138}
139
140impl AccessHistoryFilter {
141 pub fn name(&self) -> &'static str {
142 match self {
143 AccessHistoryFilter::NoFilter => "No filter",
144 AccessHistoryFilter::ServicesAccessed => "Services accessed",
145 AccessHistoryFilter::ServicesNotAccessed => "Services not accessed",
146 }
147 }
148}
149
150pub struct State {
151 pub users: TableState<IamUser>,
152 pub current_user: Option<String>,
153 pub user_tab: UserTab,
154 pub user_input_focus: InputFocus,
155 pub roles: TableState<IamRole>,
156 pub current_role: Option<String>,
157 pub role_tab: RoleTab,
158 pub role_input_focus: InputFocus,
159 pub groups: TableState<IamGroup>,
160 pub current_group: Option<String>,
161 pub group_tab: GroupTab,
162 pub group_input_focus: InputFocus,
163 pub policies: TableState<Policy>,
164 pub policy_type_filter: String,
165 pub policy_input_focus: InputFocus,
166 pub current_policy: Option<String>,
167 pub policy_document: String,
168 pub policy_scroll: usize,
169 pub trust_policy_document: String,
170 pub trust_policy_scroll: usize,
171 pub tags: TableState<RoleTag>,
172 pub user_tags: TableState<UserTag>,
173 pub user_group_memberships: TableState<UserGroup>,
174 pub group_users: TableState<GroupUser>,
175 pub last_accessed_services: TableState<LastAccessedService>,
176 pub last_accessed_filter: String,
177 pub last_accessed_history_filter: AccessHistoryFilter,
178 pub last_accessed_input_focus: InputFocus,
179 pub revoke_sessions_scroll: usize,
180}
181
182impl Default for State {
183 fn default() -> Self {
184 Self::new()
185 }
186}
187
188impl State {
189 pub fn new() -> Self {
190 Self {
191 users: TableState::new(),
192 current_user: None,
193 user_tab: UserTab::Permissions,
194 user_input_focus: InputFocus::Filter,
195 roles: TableState::new(),
196 current_role: None,
197 role_tab: RoleTab::Permissions,
198 role_input_focus: InputFocus::Filter,
199 groups: TableState::new(),
200 current_group: None,
201 group_tab: GroupTab::Users,
202 group_input_focus: InputFocus::Filter,
203 policies: TableState::new(),
204 policy_type_filter: "All types".to_string(),
205 policy_input_focus: InputFocus::Filter,
206 current_policy: None,
207 policy_document: String::new(),
208 policy_scroll: 0,
209 trust_policy_document: String::new(),
210 trust_policy_scroll: 0,
211 tags: TableState::new(),
212 user_tags: TableState::new(),
213 user_group_memberships: TableState::new(),
214 group_users: TableState::new(),
215 last_accessed_services: TableState::new(),
216 last_accessed_filter: String::new(),
217 last_accessed_history_filter: AccessHistoryFilter::NoFilter,
218 last_accessed_input_focus: InputFocus::Filter,
219 revoke_sessions_scroll: 0,
220 }
221 }
222}
223
224pub fn render_users(frame: &mut Frame, app: &App, area: Rect) {
225 frame.render_widget(Clear, area);
226
227 if app.iam_state.current_user.is_some() {
228 render_user_detail(frame, app, area);
229 } else {
230 render_user_list(frame, app, area);
231 }
232}
233
234pub fn render_user_list(frame: &mut Frame, app: &App, area: Rect) {
235 let chunks = vertical(
236 [
237 Constraint::Length(3), Constraint::Min(0), ],
240 area,
241 );
242
243 let filtered_users = filtered_iam_users(app);
245 let filtered_count = filtered_users.len();
246 let page_size = app.iam_state.users.page_size.value();
247 render_search_filter(
248 frame,
249 chunks[0],
250 &app.iam_state.users.filter,
251 app.mode == Mode::FilterInput,
252 app.iam_state.users.selected,
253 filtered_count,
254 page_size,
255 );
256
257 let scroll_offset = app.iam_state.users.scroll_offset;
259 let page_users: Vec<_> = filtered_users
260 .iter()
261 .skip(scroll_offset)
262 .take(page_size)
263 .collect();
264
265 let columns: Vec<Box<dyn Column<&IamUser>>> = app
266 .iam_user_visible_column_ids
267 .iter()
268 .filter_map(|col_id| {
269 UserColumn::from_id(col_id).map(|col| Box::new(col) as Box<dyn Column<&IamUser>>)
270 })
271 .collect();
272
273 let expanded_index = app.iam_state.users.expanded_item.and_then(|idx| {
274 if idx >= scroll_offset && idx < scroll_offset + page_size {
275 Some(idx - scroll_offset)
276 } else {
277 None
278 }
279 });
280
281 let config = TableConfig {
282 items: page_users,
283 selected_index: app.iam_state.users.selected - app.iam_state.users.scroll_offset,
284 expanded_index,
285 columns: &columns,
286 sort_column: "User name",
287 sort_direction: SortDirection::Asc,
288 title: format_title(&format!("Users ({})", filtered_count)),
289 area: chunks[1],
290 get_expanded_content: Some(Box::new(|user: &&IamUser| {
291 expanded_from_columns(&columns, user)
292 })),
293 is_active: app.mode != Mode::FilterInput,
294 };
295
296 render_table(frame, config);
297}
298
299pub fn render_roles(frame: &mut Frame, app: &App, area: Rect) {
300 frame.render_widget(Clear, area);
301
302 if app.view_mode == ViewMode::PolicyView {
303 render_policy_view(frame, app, area);
304 } else if app.iam_state.current_role.is_some() {
305 render_role_detail(frame, app, area);
306 } else {
307 render_role_list(frame, app, area);
308 }
309}
310
311pub fn render_user_groups(frame: &mut Frame, app: &App, area: Rect) {
312 frame.render_widget(Clear, area);
313
314 if app.iam_state.current_group.is_some() {
315 render_group_detail(frame, app, area);
316 } else {
317 render_group_list(frame, app, area);
318 }
319}
320
321pub fn render_group_detail(frame: &mut Frame, app: &App, area: Rect) {
322 let summary_height = if app.iam_state.current_group.is_some() {
324 block_height_for(3) } else {
326 0
327 };
328
329 let chunks = vertical(
330 [
331 Constraint::Length(summary_height), Constraint::Length(1), Constraint::Min(0), ],
335 area,
336 );
337
338 if let Some(group_name) = &app.iam_state.current_group {
339 if let Some(group) = app
341 .iam_state
342 .groups
343 .items
344 .iter()
345 .find(|g| g.group_name == *group_name)
346 {
347 render_summary(
348 frame,
349 chunks[0],
350 " Summary ",
351 &[
352 ("User group name: ", group.group_name.clone()),
353 ("Creation time: ", group.creation_time.clone()),
354 (
355 "ARN: ",
356 format!(
357 "arn:aws:iam::{}:group/{}",
358 app.config.account_id, group.group_name
359 ),
360 ),
361 ],
362 );
363 }
364
365 let tabs: Vec<(&str, GroupTab)> =
367 GroupTab::ALL.iter().map(|tab| (tab.name(), *tab)).collect();
368 render_tabs(frame, chunks[1], &tabs, &app.iam_state.group_tab);
369
370 match app.iam_state.group_tab {
372 GroupTab::Users => {
373 render_group_users_tab(frame, app, chunks[2]);
374 }
375 GroupTab::Permissions => {
376 render_permissions_section(
377 frame,
378 chunks[2],
379 "You can attach up to 10 managed policies.",
380 |f, area| render_policies_table(f, app, area),
381 );
382 }
383 GroupTab::AccessAdvisor => {
384 render_group_access_advisor_tab(frame, app, chunks[2]);
385 }
386 }
387 }
388}
389
390pub fn render_group_users_tab(frame: &mut Frame, app: &App, area: Rect) {
391 render_group_users_table(frame, app, area);
392}
393
394pub fn render_group_users_table(frame: &mut Frame, app: &App, area: Rect) {
395 let chunks = vertical(
396 [
397 Constraint::Length(3), Constraint::Min(0), ],
400 area,
401 );
402
403 let page_size = app.iam_state.group_users.page_size.value();
404 let filtered_users: Vec<_> = app
405 .iam_state
406 .group_users
407 .items
408 .iter()
409 .filter(|u| {
410 if app.iam_state.group_users.filter.is_empty() {
411 true
412 } else {
413 u.user_name
414 .to_lowercase()
415 .contains(&app.iam_state.group_users.filter.to_lowercase())
416 }
417 })
418 .collect();
419
420 let filtered_count = filtered_users.len();
421 let total_pages = filtered_count.div_ceil(page_size);
422 let current_page = app.iam_state.group_users.selected / page_size;
423 let pagination = render_pagination_text(current_page, total_pages);
424
425 render_simple_filter(
426 frame,
427 chunks[0],
428 SimpleFilterConfig {
429 filter_text: &app.iam_state.group_users.filter,
430 placeholder: "Search",
431 pagination: &pagination,
432 mode: app.mode,
433 is_input_focused: true,
434 is_pagination_focused: false,
435 },
436 );
437
438 let scroll_offset = app.iam_state.group_users.scroll_offset;
439 let page_users: Vec<&GroupUser> = filtered_users
440 .into_iter()
441 .skip(scroll_offset)
442 .take(page_size)
443 .collect();
444
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 = 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_title(&format!(
468 "Users ({})",
469 app.iam_state.group_users.items.len()
470 )),
471 area: chunks[1],
472 is_active: app.mode != Mode::ColumnSelector,
473 get_expanded_content: Some(Box::new(|user: &GroupUser| {
474 plain_expanded_content(format!(
475 "User name: {}\nGroups: {}\nLast activity: {}\nCreation time: {}",
476 user.user_name, user.groups, user.last_activity, user.creation_time
477 ))
478 })),
479 };
480
481 render_table(frame, config);
482}
483
484pub fn render_group_access_advisor_tab(frame: &mut Frame, app: &App, area: Rect) {
485 render_last_accessed_table(frame, app, area);
486}
487
488pub fn render_group_list(frame: &mut Frame, app: &App, area: Rect) {
489 let chunks = vertical(
490 [
491 Constraint::Length(3), Constraint::Min(0), ],
494 area,
495 );
496
497 let page_size = app.iam_state.groups.page_size.value();
498 let filtered_groups: Vec<_> = app
499 .iam_state
500 .groups
501 .items
502 .iter()
503 .filter(|g| {
504 if app.iam_state.groups.filter.is_empty() {
505 true
506 } else {
507 g.group_name
508 .to_lowercase()
509 .contains(&app.iam_state.groups.filter.to_lowercase())
510 }
511 })
512 .collect();
513
514 let filtered_count = filtered_groups.len();
515 let total_pages = filtered_count.div_ceil(page_size);
516 let current_page = app.iam_state.groups.selected / page_size;
517 let pagination = render_pagination_text(current_page, total_pages);
518
519 use crate::ui::filter::{render_simple_filter, SimpleFilterConfig};
520 render_simple_filter(
521 frame,
522 chunks[0],
523 SimpleFilterConfig {
524 filter_text: &app.iam_state.groups.filter,
525 placeholder: "Search",
526 pagination: &pagination,
527 mode: app.mode,
528 is_input_focused: app.iam_state.group_input_focus == InputFocus::Filter,
529 is_pagination_focused: app.iam_state.group_input_focus == InputFocus::Pagination,
530 },
531 );
532
533 let scroll_offset = app.iam_state.groups.scroll_offset;
534 let page_groups: Vec<&IamGroup> = filtered_groups
535 .into_iter()
536 .skip(scroll_offset)
537 .take(page_size)
538 .collect();
539
540 let mut columns: Vec<Box<dyn Column<IamGroup>>> = vec![];
541 for col_name in &app.iam_group_visible_column_ids {
542 let column = match col_name.as_str() {
543 "Group name" => Some(GroupColumn::GroupName),
544 "Path" => Some(GroupColumn::Path),
545 "Users" => Some(GroupColumn::Users),
546 "Permissions" => Some(GroupColumn::Permissions),
547 "Creation time" => Some(GroupColumn::CreationTime),
548 _ => None,
549 };
550 if let Some(c) = column {
551 columns.push(Box::new(c));
552 }
553 }
554
555 let expanded_index = app.iam_state.groups.expanded_item.and_then(|idx| {
556 if idx >= scroll_offset && idx < scroll_offset + page_size {
557 Some(idx - scroll_offset)
558 } else {
559 None
560 }
561 });
562
563 let config = TableConfig {
564 items: page_groups,
565 selected_index: app.iam_state.groups.selected - scroll_offset,
566 expanded_index,
567 columns: &columns,
568 sort_column: "Group name",
569 sort_direction: SortDirection::Asc,
570 title: format_title(&format!(
571 "User groups ({})",
572 app.iam_state.groups.items.len()
573 )),
574 area: chunks[1],
575 is_active: app.mode != Mode::ColumnSelector,
576 get_expanded_content: Some(Box::new(|group: &IamGroup| {
577 expanded_from_columns(&columns, group)
578 })),
579 };
580
581 render_table(frame, config);
582}
583
584pub fn render_role_list(frame: &mut Frame, app: &App, area: Rect) {
585 let chunks = vertical(
586 [
587 Constraint::Length(3), Constraint::Min(0), ],
590 area,
591 );
592
593 let page_size = app.iam_state.roles.page_size.value();
595 let filtered_count = filtered_iam_roles(app).len();
596 let total_pages = if filtered_count == 0 {
597 1
598 } else {
599 filtered_count.div_ceil(page_size)
600 };
601 let current_page = if filtered_count == 0 {
602 0
603 } else {
604 app.iam_state.roles.selected / page_size
605 };
606 let pagination = render_pagination_text(current_page, total_pages);
607
608 render_simple_filter(
609 frame,
610 chunks[0],
611 SimpleFilterConfig {
612 filter_text: &app.iam_state.roles.filter,
613 placeholder: "Search",
614 pagination: &pagination,
615 mode: app.mode,
616 is_input_focused: app.iam_state.role_input_focus == InputFocus::Filter,
617 is_pagination_focused: app.iam_state.role_input_focus == InputFocus::Pagination,
618 },
619 );
620
621 let scroll_offset = app.iam_state.roles.scroll_offset;
623 let page_roles: Vec<_> = filtered_iam_roles(app)
624 .into_iter()
625 .skip(scroll_offset)
626 .take(page_size)
627 .collect();
628
629 let mut columns: Vec<Box<dyn Column<IamRole>>> = vec![];
630 for col_id in &app.iam_role_visible_column_ids {
631 if let Some(col) = RoleColumn::from_id(col_id) {
632 columns.push(Box::new(col));
633 }
634 }
635
636 let expanded_index = app.iam_state.roles.expanded_item.and_then(|idx| {
637 if idx >= scroll_offset && idx < scroll_offset + page_size {
638 Some(idx - scroll_offset)
639 } else {
640 None
641 }
642 });
643
644 let config = TableConfig {
645 items: page_roles,
646 selected_index: app.iam_state.roles.selected % page_size,
647 expanded_index,
648 columns: &columns,
649 sort_column: "Role name",
650 sort_direction: SortDirection::Asc,
651 title: format_title(&format!("Roles ({})", filtered_count)),
652 area: chunks[1],
653 is_active: app.mode != Mode::ColumnSelector,
654 get_expanded_content: Some(Box::new(|role: &IamRole| {
655 expanded_from_columns(&columns, role)
656 })),
657 };
658
659 render_table(frame, config);
660}
661
662pub fn render_role_detail(frame: &mut Frame, app: &App, area: Rect) {
663 frame.render_widget(Clear, area);
664
665 let summary_height = if app.iam_state.current_role.is_some() {
667 block_height_for(5) } else {
669 0
670 };
671
672 let chunks = vertical(
673 [
674 Constraint::Length(summary_height), Constraint::Length(1), Constraint::Min(0), ],
678 area,
679 );
680
681 if let Some(role_name) = &app.iam_state.current_role {
683 if let Some(role) = app
684 .iam_state
685 .roles
686 .items
687 .iter()
688 .find(|r| r.role_name == *role_name)
689 {
690 let formatted_duration = role
691 .max_session_duration
692 .map(format_duration_seconds)
693 .unwrap_or_default();
694
695 render_summary(
696 frame,
697 chunks[0],
698 " Summary ",
699 &[
700 ("ARN: ", role.arn.clone()),
701 ("Trusted entities: ", role.trusted_entities.clone()),
702 ("Max session duration: ", formatted_duration),
703 ("Created: ", role.creation_time.clone()),
704 ("Description: ", role.description.clone()),
705 ],
706 );
707 }
708 }
709
710 render_tabs(
712 frame,
713 chunks[1],
714 &RoleTab::ALL
715 .iter()
716 .map(|tab| (tab.name(), *tab))
717 .collect::<Vec<_>>(),
718 &app.iam_state.role_tab,
719 );
720
721 match app.iam_state.role_tab {
723 RoleTab::Permissions => {
724 render_permissions_section(
725 frame,
726 chunks[2],
727 "You can attach up to 10 managed policies.",
728 |f, area| render_policies_table(f, app, area),
729 );
730 }
731 RoleTab::TrustRelationships => {
732 render_json_highlighted(
733 frame,
734 chunks[2],
735 &app.iam_state.trust_policy_document,
736 app.iam_state.trust_policy_scroll,
737 "Trust Policy",
738 true,
739 );
740 }
741 RoleTab::Tags => {
742 render_tags_section(frame, chunks[2], |f, area| render_tags_table(f, app, area));
743 }
744 RoleTab::RevokeSessions => {
745 let example_policy = r#"{
746 "Version": "2012-10-17",
747 "Statement": [
748 {
749 "Effect": "Deny",
750 "Action": [
751 "*"
752 ],
753 "Resource": [
754 "*"
755 ],
756 "Condition": {
757 "DateLessThan": {
758 "aws:TokenIssueTime": "[policy creation time]"
759 }
760 }
761 }
762 ]
763}"#;
764
765 render_json_highlighted(
766 frame,
767 chunks[2],
768 example_policy,
769 app.iam_state.revoke_sessions_scroll,
770 " Example Policy ",
771 true,
772 );
773 }
774 RoleTab::LastAccessed => {
775 render_last_accessed_section(
776 frame,
777 chunks[2],
778 "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.",
779 "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.",
780 |f, area| render_last_accessed_table(f, app, area),
781 );
782 }
783 }
784}
785
786pub fn render_user_detail(frame: &mut Frame, app: &App, area: Rect) {
787 frame.render_widget(Clear, area);
788
789 let summary_lines = if let Some(user_name) = &app.iam_state.current_user {
791 if let Some(user) = app
792 .iam_state
793 .users
794 .items
795 .iter()
796 .find(|u| u.user_name == *user_name)
797 {
798 vec![
799 labeled_field("ARN", &user.arn),
800 labeled_field("Console access", &user.console_access),
801 labeled_field("Access key", &user.access_key_id),
802 labeled_field("Created", &user.creation_time),
803 labeled_field("Last console sign-in", &user.console_last_sign_in),
804 ]
805 } else {
806 vec![]
807 }
808 } else {
809 vec![]
810 };
811
812 let summary_height = if summary_lines.is_empty() {
814 0
815 } else {
816 calculate_dynamic_height(&summary_lines, area.width.saturating_sub(4)) + 2
817 };
818
819 let chunks = vertical(
820 [
821 Constraint::Length(summary_height), Constraint::Length(1), Constraint::Min(0), ],
825 area,
826 );
827
828 if !summary_lines.is_empty() {
830 let summary_block = titled_block("Summary");
831
832 let summary_inner = summary_block.inner(chunks[0]);
833 frame.render_widget(summary_block, chunks[0]);
834
835 render_fields_with_dynamic_columns(frame, summary_inner, summary_lines);
836 }
837
838 render_tabs(
840 frame,
841 chunks[1],
842 &UserTab::ALL
843 .iter()
844 .map(|tab| (tab.name(), *tab))
845 .collect::<Vec<_>>(),
846 &app.iam_state.user_tab,
847 );
848
849 if app.iam_state.user_tab == UserTab::Permissions {
851 render_permissions_tab(frame, app, chunks[2]);
852 } else if app.iam_state.user_tab == UserTab::Groups {
853 render_user_groups_tab(frame, app, chunks[2]);
854 } else if app.iam_state.user_tab == UserTab::Tags {
855 render_tags_section(frame, chunks[2], |f, area| {
856 render_user_tags_table(f, app, area)
857 });
858 } else if app.iam_state.user_tab == UserTab::SecurityCredentials {
859 let block = titled_block("Security Credentials");
860 let inner = block.inner(area);
861 frame.render_widget(block, area);
862
863 let text = Paragraph::new("Security credentials information is not yet implemented.");
864 frame.render_widget(text, inner);
865 } else if app.iam_state.user_tab == UserTab::LastAccessed {
866 render_user_last_accessed_tab(frame, app, chunks[2]);
867 }
868}
869
870pub fn render_permissions_tab(frame: &mut Frame, app: &App, area: Rect) {
871 render_permissions_section(
872 frame,
873 area,
874 "Permissions are defined by policies attached to the user directly or through groups.",
875 |f, area| render_policies_table(f, app, area),
876 );
877}
878
879pub fn render_policy_view(frame: &mut Frame, app: &App, area: Rect) {
880 frame.render_widget(Clear, area);
881
882 render_json_highlighted(
883 frame,
884 area,
885 &app.iam_state.policy_document,
886 app.iam_state.policy_scroll,
887 " Policy Document ",
888 true,
889 );
890}
891
892pub fn render_policies_table(frame: &mut Frame, app: &App, area: Rect) {
893 let chunks = vertical(
894 [
895 Constraint::Length(3), Constraint::Min(0), ],
898 area,
899 );
900
901 let page_size = app.iam_state.policies.page_size.value();
903 let filtered_policies: Vec<_> = app
904 .iam_state
905 .policies
906 .items
907 .iter()
908 .filter(|p| {
909 let matches_filter = if app.iam_state.policies.filter.is_empty() {
910 true
911 } else {
912 p.policy_name
913 .to_lowercase()
914 .contains(&app.iam_state.policies.filter.to_lowercase())
915 };
916 let matches_type = if app.iam_state.policy_type_filter == "All types" {
917 true
918 } else {
919 p.policy_type == app.iam_state.policy_type_filter
920 };
921 matches_filter && matches_type
922 })
923 .collect();
924
925 let filtered_count = filtered_policies.len();
926 let total_pages = filtered_count.div_ceil(page_size);
927 let current_page = app.iam_state.policies.selected / page_size;
928 let pagination = render_pagination_text(current_page, total_pages);
929 let policy_type_text = format!("Type: {}", app.iam_state.policy_type_filter);
930
931 use crate::ui::filter::{render_filter_bar, FilterConfig, FilterControl};
932 render_filter_bar(
933 frame,
934 FilterConfig {
935 filter_text: &app.iam_state.policies.filter,
936 placeholder: "Search",
937 mode: app.mode,
938 is_input_focused: app.iam_state.policy_input_focus == InputFocus::Filter,
939 controls: vec![
940 FilterControl {
941 text: policy_type_text,
942 is_focused: app.iam_state.policy_input_focus == POLICY_TYPE_DROPDOWN,
943 },
944 FilterControl {
945 text: pagination.clone(),
946 is_focused: app.iam_state.policy_input_focus == InputFocus::Pagination,
947 },
948 ],
949 area: chunks[0],
950 },
951 );
952
953 let scroll_offset = app.iam_state.policies.scroll_offset;
955 let page_policies: Vec<&Policy> = filtered_policies
956 .into_iter()
957 .skip(scroll_offset)
958 .take(page_size)
959 .collect();
960
961 let mut columns: Vec<Box<dyn Column<Policy>>> = vec![];
963 for col in &app.iam_policy_visible_column_ids {
964 match col.as_str() {
965 "Policy name" => columns.push(Box::new(PolicyColumn::PolicyName)),
966 "Type" => columns.push(Box::new(PolicyColumn::Type)),
967 "Attached via" => columns.push(Box::new(PolicyColumn::AttachedVia)),
968 "Attached entities" => columns.push(Box::new(PolicyColumn::AttachedEntities)),
969 "Description" => columns.push(Box::new(PolicyColumn::Description)),
970 "Creation time" => columns.push(Box::new(PolicyColumn::CreationTime)),
971 "Edited time" => columns.push(Box::new(PolicyColumn::EditedTime)),
972 _ => {}
973 }
974 }
975
976 let expanded_index = app.iam_state.policies.expanded_item.and_then(|idx| {
977 if idx >= scroll_offset && idx < scroll_offset + page_size {
978 Some(idx - scroll_offset)
979 } else {
980 None
981 }
982 });
983
984 let config = TableConfig {
985 items: page_policies,
986 selected_index: app.iam_state.policies.selected - scroll_offset,
987 expanded_index,
988 columns: &columns,
989 sort_column: "Policy name",
990 sort_direction: SortDirection::Asc,
991 title: format_title(&format!("Permissions policies ({})", filtered_count)),
992 area: chunks[1],
993 is_active: app.mode != Mode::ColumnSelector,
994 get_expanded_content: Some(Box::new(|policy: &Policy| {
995 expanded_from_columns(&columns, policy)
996 })),
997 };
998
999 render_table(frame, config);
1000
1001 if app.mode == Mode::FilterInput && app.iam_state.policy_input_focus == POLICY_TYPE_DROPDOWN {
1003 use crate::common::render_dropdown;
1004 let policy_types = ["All types", "AWS managed", "Customer managed"];
1005 let selected_idx = policy_types
1006 .iter()
1007 .position(|&t| t == app.iam_state.policy_type_filter)
1008 .unwrap_or(0);
1009 let controls_after = pagination.len() as u16 + 3;
1010 render_dropdown(
1011 frame,
1012 &policy_types,
1013 selected_idx,
1014 chunks[0],
1015 controls_after,
1016 );
1017 }
1018}
1019
1020pub fn render_tags_table(frame: &mut Frame, app: &App, area: Rect) {
1021 let chunks = vertical(
1022 [
1023 Constraint::Length(3), Constraint::Min(0), ],
1026 area,
1027 );
1028
1029 let page_size = app.iam_state.tags.page_size.value();
1031 let filtered_tags: Vec<_> = app
1032 .iam_state
1033 .tags
1034 .items
1035 .iter()
1036 .filter(|t| {
1037 if app.iam_state.tags.filter.is_empty() {
1038 true
1039 } else {
1040 t.key
1041 .to_lowercase()
1042 .contains(&app.iam_state.tags.filter.to_lowercase())
1043 || t.value
1044 .to_lowercase()
1045 .contains(&app.iam_state.tags.filter.to_lowercase())
1046 }
1047 })
1048 .collect();
1049
1050 let filtered_count = filtered_tags.len();
1051 let total_pages = filtered_count.div_ceil(page_size);
1052 let current_page = app.iam_state.tags.selected / page_size;
1053 let pagination = render_pagination_text(current_page, total_pages);
1054
1055 use crate::ui::filter::{render_simple_filter, SimpleFilterConfig};
1056 render_simple_filter(
1057 frame,
1058 chunks[0],
1059 SimpleFilterConfig {
1060 filter_text: &app.iam_state.tags.filter,
1061 placeholder: "Search",
1062 pagination: &pagination,
1063 mode: app.mode,
1064 is_input_focused: app.iam_state.role_input_focus == InputFocus::Filter,
1065 is_pagination_focused: app.iam_state.role_input_focus == InputFocus::Pagination,
1066 },
1067 );
1068
1069 let scroll_offset = app.iam_state.tags.scroll_offset;
1071 let page_tags: Vec<&RoleTag> = filtered_tags
1072 .into_iter()
1073 .skip(scroll_offset)
1074 .take(page_size)
1075 .collect();
1076
1077 use crate::iam::TagColumn;
1078 let columns: Vec<Box<dyn Column<RoleTag>>> =
1079 vec![Box::new(TagColumn::Key), Box::new(TagColumn::Value)];
1080
1081 let expanded_index = app.iam_state.tags.expanded_item.and_then(|idx| {
1082 if idx >= scroll_offset && idx < scroll_offset + page_size {
1083 Some(idx - scroll_offset)
1084 } else {
1085 None
1086 }
1087 });
1088
1089 let config = TableConfig {
1090 items: page_tags,
1091 selected_index: app.iam_state.tags.selected - scroll_offset,
1092 expanded_index,
1093 columns: &columns,
1094 sort_column: "",
1095 sort_direction: SortDirection::Asc,
1096 title: format_title(&format!("Tags ({})", app.iam_state.tags.items.len())),
1097 area: chunks[1],
1098 is_active: true,
1099 get_expanded_content: Some(Box::new(|tag: &RoleTag| {
1100 plain_expanded_content(format!("Key: {}\nValue: {}", tag.key, tag.value))
1101 })),
1102 };
1103
1104 render_table(frame, config);
1105}
1106
1107pub fn render_user_groups_tab(frame: &mut Frame, app: &App, area: Rect) {
1108 render_user_groups_table(frame, app, area);
1109}
1110
1111pub fn render_user_groups_table(frame: &mut Frame, app: &App, area: Rect) {
1112 let chunks = vertical(
1113 [
1114 Constraint::Length(3), Constraint::Min(0), ],
1117 area,
1118 );
1119
1120 let page_size = app.iam_state.user_group_memberships.page_size.value();
1121 let filtered_groups: Vec<_> = app
1122 .iam_state
1123 .user_group_memberships
1124 .items
1125 .iter()
1126 .filter(|g| {
1127 if app.iam_state.user_group_memberships.filter.is_empty() {
1128 true
1129 } else {
1130 g.group_name
1131 .to_lowercase()
1132 .contains(&app.iam_state.user_group_memberships.filter.to_lowercase())
1133 }
1134 })
1135 .collect();
1136
1137 let filtered_count = filtered_groups.len();
1138 let total_pages = filtered_count.div_ceil(page_size);
1139 let current_page = app.iam_state.user_group_memberships.selected / page_size;
1140 let pagination = render_pagination_text(current_page, total_pages);
1141
1142 use crate::ui::filter::{render_simple_filter, SimpleFilterConfig};
1143 render_simple_filter(
1144 frame,
1145 chunks[0],
1146 SimpleFilterConfig {
1147 filter_text: &app.iam_state.user_group_memberships.filter,
1148 placeholder: "Search",
1149 pagination: &pagination,
1150 mode: app.mode,
1151 is_input_focused: app.iam_state.user_input_focus == InputFocus::Filter,
1152 is_pagination_focused: app.iam_state.user_input_focus == InputFocus::Pagination,
1153 },
1154 );
1155
1156 let scroll_offset = app.iam_state.user_group_memberships.scroll_offset;
1157 let page_groups: Vec<&UserGroup> = filtered_groups
1158 .into_iter()
1159 .skip(scroll_offset)
1160 .take(page_size)
1161 .collect();
1162
1163 use crate::iam::UserGroupColumn;
1164 let columns: Vec<Box<dyn Column<UserGroup>>> = vec![
1165 Box::new(UserGroupColumn::GroupName),
1166 Box::new(UserGroupColumn::AttachedPolicies),
1167 ];
1168
1169 let expanded_index = app
1170 .iam_state
1171 .user_group_memberships
1172 .expanded_item
1173 .and_then(|idx| {
1174 if idx >= scroll_offset && idx < scroll_offset + page_size {
1175 Some(idx - scroll_offset)
1176 } else {
1177 None
1178 }
1179 });
1180
1181 let config = TableConfig {
1182 items: page_groups,
1183 selected_index: app.iam_state.user_group_memberships.selected - scroll_offset,
1184 expanded_index,
1185 columns: &columns,
1186 sort_column: "",
1187 sort_direction: SortDirection::Asc,
1188 title: format_title(&format!(
1189 "User groups membership ({})",
1190 app.iam_state.user_group_memberships.items.len()
1191 )),
1192 area: chunks[1],
1193 is_active: true,
1194 get_expanded_content: Some(Box::new(|group: &UserGroup| {
1195 plain_expanded_content(format!(
1196 "Group: {}\nAttached policies: {}",
1197 group.group_name, group.attached_policies
1198 ))
1199 })),
1200 };
1201
1202 render_table(frame, config);
1203}
1204
1205pub fn render_user_last_accessed_tab(frame: &mut Frame, app: &App, area: Rect) {
1206 render_last_accessed_table(frame, app, area);
1207}
1208
1209pub fn render_user_tags_table(frame: &mut Frame, app: &App, area: Rect) {
1210 let chunks = vertical(
1211 [
1212 Constraint::Length(3), Constraint::Min(0), ],
1215 area,
1216 );
1217
1218 let page_size = app.iam_state.user_tags.page_size.value();
1220 let filtered_tags: Vec<_> = app
1221 .iam_state
1222 .user_tags
1223 .items
1224 .iter()
1225 .filter(|t| {
1226 if app.iam_state.user_tags.filter.is_empty() {
1227 true
1228 } else {
1229 t.key
1230 .to_lowercase()
1231 .contains(&app.iam_state.user_tags.filter.to_lowercase())
1232 || t.value
1233 .to_lowercase()
1234 .contains(&app.iam_state.user_tags.filter.to_lowercase())
1235 }
1236 })
1237 .collect();
1238
1239 let filtered_count = filtered_tags.len();
1240 let total_pages = filtered_count.div_ceil(page_size);
1241 let current_page = app.iam_state.user_tags.selected / page_size;
1242 let pagination = render_pagination_text(current_page, total_pages);
1243
1244 use crate::ui::filter::{render_simple_filter, SimpleFilterConfig};
1245 render_simple_filter(
1246 frame,
1247 chunks[0],
1248 SimpleFilterConfig {
1249 filter_text: &app.iam_state.user_tags.filter,
1250 placeholder: "Search",
1251 pagination: &pagination,
1252 mode: app.mode,
1253 is_input_focused: app.iam_state.user_input_focus == InputFocus::Filter,
1254 is_pagination_focused: app.iam_state.user_input_focus == InputFocus::Pagination,
1255 },
1256 );
1257
1258 let scroll_offset = app.iam_state.user_tags.scroll_offset;
1260 let page_tags: Vec<&UserTag> = filtered_tags
1261 .into_iter()
1262 .skip(scroll_offset)
1263 .take(page_size)
1264 .collect();
1265
1266 use crate::iam::TagColumn;
1267 let columns: Vec<Box<dyn Column<UserTag>>> =
1268 vec![Box::new(TagColumn::Key), Box::new(TagColumn::Value)];
1269
1270 let expanded_index = app.iam_state.user_tags.expanded_item.and_then(|idx| {
1271 if idx >= scroll_offset && idx < scroll_offset + page_size {
1272 Some(idx - scroll_offset)
1273 } else {
1274 None
1275 }
1276 });
1277
1278 let config = TableConfig {
1279 items: page_tags,
1280 selected_index: app.iam_state.user_tags.selected - scroll_offset,
1281 expanded_index,
1282 columns: &columns,
1283 sort_column: "",
1284 sort_direction: SortDirection::Asc,
1285 title: format_title(&format!("Tags ({})", app.iam_state.user_tags.items.len())),
1286 area: chunks[1],
1287 is_active: true,
1288 get_expanded_content: Some(Box::new(|tag: &UserTag| {
1289 plain_expanded_content(format!("Key: {}\nValue: {}", tag.key, tag.value))
1290 })),
1291 };
1292
1293 render_table(frame, config);
1294}
1295
1296pub fn render_last_accessed_table(frame: &mut Frame, app: &App, area: Rect) {
1297 let chunks = vertical(
1298 [
1299 Constraint::Length(3), Constraint::Min(0), ],
1302 area,
1303 );
1304
1305 let page_size = app.iam_state.last_accessed_services.page_size.value();
1307 let filtered_services: Vec<_> = app
1308 .iam_state
1309 .last_accessed_services
1310 .items
1311 .iter()
1312 .filter(|s| {
1313 let matches_filter = if app.iam_state.last_accessed_filter.is_empty() {
1314 true
1315 } else {
1316 s.service
1317 .to_lowercase()
1318 .contains(&app.iam_state.last_accessed_filter.to_lowercase())
1319 };
1320 let matches_history = match app.iam_state.last_accessed_history_filter {
1321 AccessHistoryFilter::NoFilter => true,
1322 AccessHistoryFilter::ServicesAccessed => {
1323 !s.last_accessed.is_empty() && s.last_accessed != "Not accessed"
1324 }
1325 AccessHistoryFilter::ServicesNotAccessed => {
1326 s.last_accessed.is_empty() || s.last_accessed == "Not accessed"
1327 }
1328 };
1329 matches_filter && matches_history
1330 })
1331 .collect();
1332
1333 let filtered_count = filtered_services.len();
1334 let total_pages = filtered_count.div_ceil(page_size);
1335 let current_page = app.iam_state.last_accessed_services.selected / page_size;
1336 let pagination = render_pagination_text(current_page, total_pages);
1337
1338 let history_filter_text = format!(
1339 "Filter by access history: {}",
1340 app.iam_state.last_accessed_history_filter.name()
1341 );
1342
1343 use crate::ui::filter::{render_filter_bar, FilterConfig, FilterControl};
1344 render_filter_bar(
1345 frame,
1346 FilterConfig {
1347 filter_text: &app.iam_state.last_accessed_filter,
1348 placeholder: "Search",
1349 mode: app.mode,
1350 is_input_focused: app.iam_state.last_accessed_input_focus == InputFocus::Filter,
1351 controls: vec![
1352 FilterControl {
1353 text: history_filter_text,
1354 is_focused: app.iam_state.last_accessed_input_focus == HISTORY_FILTER,
1355 },
1356 FilterControl {
1357 text: pagination.clone(),
1358 is_focused: app.iam_state.last_accessed_input_focus == InputFocus::Pagination,
1359 },
1360 ],
1361 area: chunks[0],
1362 },
1363 );
1364
1365 let scroll_offset = app.iam_state.last_accessed_services.scroll_offset;
1367 let page_services: Vec<&LastAccessedService> = filtered_services
1368 .into_iter()
1369 .skip(scroll_offset)
1370 .take(page_size)
1371 .collect();
1372
1373 use crate::iam::LastAccessedServiceColumn;
1374 let columns: Vec<Box<dyn Column<LastAccessedService>>> = vec![
1375 Box::new(LastAccessedServiceColumn::Service),
1376 Box::new(LastAccessedServiceColumn::PoliciesGranting),
1377 Box::new(LastAccessedServiceColumn::LastAccessed),
1378 ];
1379
1380 let expanded_index = app
1381 .iam_state
1382 .last_accessed_services
1383 .expanded_item
1384 .and_then(|idx| {
1385 if idx >= scroll_offset && idx < scroll_offset + page_size {
1386 Some(idx - scroll_offset)
1387 } else {
1388 None
1389 }
1390 });
1391
1392 let config = TableConfig {
1393 items: page_services,
1394 selected_index: app
1395 .iam_state
1396 .last_accessed_services
1397 .selected
1398 .saturating_sub(scroll_offset),
1399 expanded_index,
1400 columns: &columns,
1401 sort_column: "Last accessed",
1402 sort_direction: SortDirection::Desc,
1403 title: format_title(&format!(
1404 "Allowed services ({})",
1405 app.iam_state.last_accessed_services.items.len()
1406 )),
1407 area: chunks[1],
1408 is_active: true,
1409 get_expanded_content: Some(Box::new(|service: &LastAccessedService| {
1410 plain_expanded_content(format!(
1411 "Service: {}\nPolicies granting permissions: {}\nLast accessed: {}",
1412 service.service, service.policies_granting, service.last_accessed
1413 ))
1414 })),
1415 };
1416
1417 render_table(frame, config);
1418
1419 if app.mode == Mode::FilterInput && app.iam_state.last_accessed_input_focus == HISTORY_FILTER {
1421 use crate::common::render_dropdown;
1422 let filter_names: Vec<&str> = AccessHistoryFilter::ALL.iter().map(|f| f.name()).collect();
1423 let selected_idx = AccessHistoryFilter::ALL
1424 .iter()
1425 .position(|f| *f == app.iam_state.last_accessed_history_filter)
1426 .unwrap_or(0);
1427 let controls_after = pagination.len() as u16 + 3;
1428 render_dropdown(
1429 frame,
1430 &filter_names,
1431 selected_idx,
1432 chunks[0],
1433 controls_after,
1434 );
1435 }
1436}
1437
1438pub fn filtered_iam_users(app: &App) -> Vec<&IamUser> {
1440 filter_by_field(
1441 &app.iam_state.users.items,
1442 &app.iam_state.users.filter,
1443 |u| &u.user_name,
1444 )
1445}
1446
1447pub fn filtered_iam_roles(app: &App) -> Vec<&IamRole> {
1448 filter_by_field(
1449 &app.iam_state.roles.items,
1450 &app.iam_state.roles.filter,
1451 |r| &r.role_name,
1452 )
1453}
1454
1455pub fn filtered_iam_policies(app: &App) -> Vec<&Policy> {
1456 app.iam_state
1457 .policies
1458 .items
1459 .iter()
1460 .filter(|p| {
1461 let matches_filter = if app.iam_state.policies.filter.is_empty() {
1462 true
1463 } else {
1464 p.policy_name
1465 .to_lowercase()
1466 .contains(&app.iam_state.policies.filter.to_lowercase())
1467 };
1468 let matches_type = if app.iam_state.policy_type_filter == "All types" {
1469 true
1470 } else {
1471 p.policy_type == app.iam_state.policy_type_filter
1472 };
1473 matches_filter && matches_type
1474 })
1475 .collect()
1476}
1477
1478pub fn filtered_tags(app: &App) -> Vec<&RoleTag> {
1479 filter_by_fields(&app.iam_state.tags.items, &app.iam_state.tags.filter, |t| {
1480 vec![&t.key, &t.value]
1481 })
1482}
1483
1484pub fn filtered_user_tags(app: &App) -> Vec<&UserTag> {
1485 filter_by_fields(
1486 &app.iam_state.user_tags.items,
1487 &app.iam_state.user_tags.filter,
1488 |t| vec![&t.key, &t.value],
1489 )
1490}
1491
1492pub fn filtered_last_accessed(app: &App) -> Vec<&LastAccessedService> {
1493 app.iam_state
1494 .last_accessed_services
1495 .items
1496 .iter()
1497 .filter(|s| {
1498 let matches_filter = if app.iam_state.last_accessed_filter.is_empty() {
1499 true
1500 } else {
1501 s.service
1502 .to_lowercase()
1503 .contains(&app.iam_state.last_accessed_filter.to_lowercase())
1504 };
1505 let matches_history = match app.iam_state.last_accessed_history_filter {
1506 AccessHistoryFilter::NoFilter => true,
1507 AccessHistoryFilter::ServicesAccessed => {
1508 !s.last_accessed.is_empty() && s.last_accessed != "Not accessed"
1509 }
1510 AccessHistoryFilter::ServicesNotAccessed => {
1511 s.last_accessed.is_empty() || s.last_accessed == "Not accessed"
1512 }
1513 };
1514 matches_filter && matches_history
1515 })
1516 .collect()
1517}
1518
1519pub async fn load_iam_users(app: &mut App) -> anyhow::Result<()> {
1520 let users = app
1521 .iam_client
1522 .list_users()
1523 .await
1524 .map_err(|e| anyhow::anyhow!(e))?;
1525
1526 let mut iam_users = Vec::new();
1527 for u in users {
1528 let user_name = u.user_name().to_string();
1529
1530 let has_console = app
1531 .iam_client
1532 .get_login_profile(&user_name)
1533 .await
1534 .unwrap_or(false);
1535 let access_key_count = app
1536 .iam_client
1537 .list_access_keys(&user_name)
1538 .await
1539 .unwrap_or(0);
1540 let creation_time = {
1541 let dt = u.create_date();
1542 let timestamp = dt.secs();
1543 let datetime = chrono::DateTime::from_timestamp(timestamp, 0).unwrap_or_default();
1544 datetime.format("%Y-%m-%d %H:%M:%S (UTC)").to_string()
1545 };
1546
1547 iam_users.push(IamUser {
1548 user_name,
1549 path: u.path().to_string(),
1550 groups: String::new(),
1551 last_activity: String::new(),
1552 mfa: String::new(),
1553 password_age: String::new(),
1554 console_last_sign_in: String::new(),
1555 access_key_id: access_key_count.to_string(),
1556 active_key_age: String::new(),
1557 access_key_last_used: String::new(),
1558 arn: u.arn().to_string(),
1559 creation_time,
1560 console_access: if has_console {
1561 "Enabled".to_string()
1562 } else {
1563 "Disabled".to_string()
1564 },
1565 signing_certs: String::new(),
1566 });
1567 }
1568
1569 app.iam_state.users.items = iam_users;
1570
1571 Ok(())
1572}
1573
1574pub async fn load_iam_roles(app: &mut App) -> anyhow::Result<()> {
1575 let roles = app
1576 .iam_client
1577 .list_roles()
1578 .await
1579 .map_err(|e| anyhow::anyhow!(e))?;
1580
1581 let roles: Vec<IamRole> = roles
1582 .into_iter()
1583 .map(|r| {
1584 let trusted_entities = r
1585 .assume_role_policy_document()
1586 .and_then(|doc| {
1587 let decoded = urlencoding::decode(doc).ok()?;
1588 let policy: serde_json::Value = serde_json::from_str(&decoded).ok()?;
1589 let statements = policy.get("Statement")?.as_array()?;
1590
1591 let mut entities = Vec::new();
1592 for stmt in statements {
1593 if let Some(principal) = stmt.get("Principal") {
1594 if let Some(service) = principal.get("Service") {
1595 if let Some(s) = service.as_str() {
1596 let clean = s.replace(".amazonaws.com", "");
1597 entities.push(format!("AWS Service: {}", clean));
1598 } else if let Some(arr) = service.as_array() {
1599 for s in arr {
1600 if let Some(s) = s.as_str() {
1601 let clean = s.replace(".amazonaws.com", "");
1602 entities.push(format!("AWS Service: {}", clean));
1603 }
1604 }
1605 }
1606 }
1607 if let Some(aws) = principal.get("AWS") {
1608 if let Some(a) = aws.as_str() {
1609 if a.starts_with("arn:aws:iam::") {
1610 if let Some(account) = a.split(':').nth(4) {
1611 entities.push(format!("Account: {}", account));
1612 }
1613 } else {
1614 entities.push(format!("Account: {}", a));
1615 }
1616 } else if let Some(arr) = aws.as_array() {
1617 for a in arr {
1618 if let Some(a) = a.as_str() {
1619 if a.starts_with("arn:aws:iam::") {
1620 if let Some(account) = a.split(':').nth(4) {
1621 entities.push(format!("Account: {}", account));
1622 }
1623 } else {
1624 entities.push(format!("Account: {}", a));
1625 }
1626 }
1627 }
1628 }
1629 }
1630 }
1631 }
1632 Some(entities.join(", "))
1633 })
1634 .unwrap_or_default();
1635
1636 let last_activity = r
1637 .role_last_used()
1638 .and_then(|last_used| {
1639 last_used.last_used_date().map(|dt| {
1640 let timestamp = dt.secs();
1641 let datetime =
1642 chrono::DateTime::from_timestamp(timestamp, 0).unwrap_or_default();
1643 datetime.format("%Y-%m-%d %H:%M:%S (UTC)").to_string()
1644 })
1645 })
1646 .or_else(|| {
1647 r.role_last_used().and_then(|last_used| {
1648 last_used
1649 .region()
1650 .map(|region| format!("Used in {}", region))
1651 })
1652 })
1653 .unwrap_or_else(|| "-".to_string());
1654
1655 IamRole {
1656 role_name: r.role_name().to_string(),
1657 path: r.path().to_string(),
1658 trusted_entities,
1659 last_activity,
1660 arn: r.arn().to_string(),
1661 creation_time: {
1662 let dt = r.create_date();
1663 let timestamp = dt.secs();
1664 let datetime =
1665 chrono::DateTime::from_timestamp(timestamp, 0).unwrap_or_default();
1666 datetime.format("%Y-%m-%d %H:%M:%S (UTC)").to_string()
1667 },
1668 description: r.description().unwrap_or("").to_string(),
1669 max_session_duration: r.max_session_duration(),
1670 }
1671 })
1672 .collect();
1673
1674 app.iam_state.roles.items = roles;
1675
1676 Ok(())
1677}
1678
1679pub async fn load_iam_user_groups(app: &mut App) -> anyhow::Result<()> {
1680 let groups = app
1681 .iam_client
1682 .list_groups()
1683 .await
1684 .map_err(|e| anyhow::anyhow!(e))?;
1685
1686 let mut iam_groups = Vec::new();
1687 for g in groups {
1688 let creation_time = {
1689 let dt = g.create_date();
1690 let timestamp = dt.secs();
1691 let datetime = chrono::DateTime::from_timestamp(timestamp, 0).unwrap_or_default();
1692 datetime.format("%Y-%m-%d %H:%M:%S (UTC)").to_string()
1693 };
1694
1695 let group_name = g.group_name().to_string();
1696 let user_count = app.iam_client.get_group(&group_name).await.unwrap_or(0);
1697
1698 iam_groups.push(IamGroup {
1699 group_name,
1700 path: g.path().to_string(),
1701 users: user_count.to_string(),
1702 permissions: "Defined".to_string(),
1703 creation_time,
1704 });
1705 }
1706
1707 app.iam_state.groups.items = iam_groups;
1708
1709 Ok(())
1710}
1711
1712#[cfg(test)]
1713mod tests {
1714 use super::*;
1715
1716 #[test]
1717 fn test_policy_input_focus_next() {
1718 assert_eq!(
1719 InputFocus::Filter.next(&POLICY_FILTER_CONTROLS),
1720 POLICY_TYPE_DROPDOWN
1721 );
1722 assert_eq!(
1723 POLICY_TYPE_DROPDOWN.next(&POLICY_FILTER_CONTROLS),
1724 InputFocus::Pagination
1725 );
1726 assert_eq!(
1727 InputFocus::Pagination.next(&POLICY_FILTER_CONTROLS),
1728 InputFocus::Filter
1729 );
1730 }
1731
1732 #[test]
1733 fn test_policy_input_focus_prev() {
1734 assert_eq!(
1735 InputFocus::Filter.prev(&POLICY_FILTER_CONTROLS),
1736 InputFocus::Pagination
1737 );
1738 assert_eq!(
1739 InputFocus::Pagination.prev(&POLICY_FILTER_CONTROLS),
1740 POLICY_TYPE_DROPDOWN
1741 );
1742 assert_eq!(
1743 POLICY_TYPE_DROPDOWN.prev(&POLICY_FILTER_CONTROLS),
1744 InputFocus::Filter
1745 );
1746 }
1747
1748 #[test]
1749 fn test_role_input_focus_next() {
1750 assert_eq!(
1751 InputFocus::Filter.next(&ROLE_FILTER_CONTROLS),
1752 InputFocus::Pagination
1753 );
1754 assert_eq!(
1755 InputFocus::Pagination.next(&ROLE_FILTER_CONTROLS),
1756 InputFocus::Filter
1757 );
1758 }
1759
1760 #[test]
1761 fn test_role_input_focus_prev() {
1762 assert_eq!(
1763 InputFocus::Filter.prev(&ROLE_FILTER_CONTROLS),
1764 InputFocus::Pagination
1765 );
1766 assert_eq!(
1767 InputFocus::Pagination.prev(&ROLE_FILTER_CONTROLS),
1768 InputFocus::Filter
1769 );
1770 }
1771
1772 #[test]
1773 fn test_rounded_block_for_summary() {
1774 use ratatui::prelude::Rect;
1775 let block = titled_block("Summary");
1776 let area = Rect::new(0, 0, 60, 15);
1777 let inner = block.inner(area);
1778 assert_eq!(inner.width, 58);
1779 assert_eq!(inner.height, 13);
1780 }
1781
1782 #[test]
1783 fn test_user_summary_uses_dynamic_height() {
1784 use crate::ui::{calculate_dynamic_height, labeled_field};
1785 let summary_lines = vec![
1787 labeled_field("ARN", "arn:aws:iam::123456789012:user/test"),
1788 labeled_field("Console access", "Enabled"),
1789 labeled_field("Access key", "AKIA..."),
1790 labeled_field("Created", "2024-01-01"),
1791 labeled_field("Last console sign-in", "2024-12-11"),
1792 ];
1793 let width = 200;
1794 let height = calculate_dynamic_height(&summary_lines, width);
1795 assert!(height <= 3, "Expected 3 rows or less, got {}", height);
1797 }
1798}