Skip to main content

rusticity_term/ui/
iam.rs

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), // Filter
239            Constraint::Min(0),    // Table
240        ],
241        area,
242    );
243
244    // Filter
245    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    // Table
259    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    // Calculate summary height
324    let summary_height = if app.iam_state.current_group.is_some() {
325        block_height_for(3) // 3 fields
326    } else {
327        0
328    };
329
330    let chunks = vertical(
331        [
332            Constraint::Length(summary_height), // Summary
333            Constraint::Length(1),              // Tabs
334            Constraint::Min(0),                 // Content
335        ],
336        area,
337    );
338
339    if let Some(group_name) = &app.iam_state.current_group {
340        // Summary section
341        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        // Tabs
367        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        // Content area based on selected tab
372        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), // Filter with pagination
399            Constraint::Min(0),    // Table
400        ],
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), // Filter
493            Constraint::Min(0),    // Table
494        ],
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), // Filter
589            Constraint::Min(0),    // Table
590        ],
591        area,
592    );
593
594    // Filter with CFN pattern
595    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    // Table
623    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    // Calculate summary height
667    let summary_height = if app.iam_state.current_role.is_some() {
668        block_height_for(5) // 5 fields
669    } else {
670        0
671    };
672
673    let chunks = vertical(
674        [
675            Constraint::Length(summary_height), // Summary
676            Constraint::Length(1),              // Tabs
677            Constraint::Min(0),                 // Content
678        ],
679        area,
680    );
681
682    // Summary section
683    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    // Tabs
712    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    // Content based on selected tab
723    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    // Build summary lines first to calculate height
791    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    // Calculate summary height
814    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), // Summary
823            Constraint::Length(1),              // Tabs
824            Constraint::Min(0),                 // Content
825        ],
826        area,
827    );
828
829    // Summary section
830    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    // Tabs
840    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    // Content area - Permissions tab
851    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), // Filter bar
897            Constraint::Min(0),    // Table
898        ],
899        area,
900    );
901
902    // Filter policies
903    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    // Table
955    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    // Define columns
963    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    // Render dropdown for policy type when focused (after table so it appears on top)
1003    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), // Filter with pagination
1049            Constraint::Min(0),    // Table
1050        ],
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), // Filter bar
1167            Constraint::Min(0),    // Table
1168        ],
1169        area,
1170    );
1171
1172    // Filter services
1173    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    // Table using common render_table
1233    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    // Render dropdown for history filter when focused (after table so it appears on top)
1287    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
1305// IAM-specific helper functions
1306pub 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        // Verify user summary height accounts for column packing
1653        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        // With 5 fields and wide width, should pack into 3 rows or less
1663        assert!(height <= 3, "Expected 3 rows or less, got {}", height);
1664    }
1665}