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