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