rusticity_term/ui/
iam.rs

1use crate::app::{App, ViewMode};
2use crate::common::CyclicEnum;
3use crate::common::{render_pagination_text, InputFocus, SortDirection};
4use crate::iam::{
5    GroupUser, IamGroup, IamRole, IamUser, LastAccessedService, Policy, RoleTag, UserGroup, UserTag,
6};
7use crate::keymap::Mode;
8use crate::table::TableState;
9use crate::ui::{
10    active_border, get_cursor, labeled_field, render_json_highlighted,
11    render_last_accessed_section, render_permissions_section, render_tags_section, vertical,
12    SEARCH_ICON,
13};
14use ratatui::{prelude::*, widgets::*};
15
16pub const POLICY_TYPE_DROPDOWN: InputFocus = InputFocus::Dropdown("PolicyType");
17pub const POLICY_FILTER_CONTROLS: [InputFocus; 3] = [
18    InputFocus::Filter,
19    POLICY_TYPE_DROPDOWN,
20    InputFocus::Pagination,
21];
22
23pub const ROLE_FILTER_CONTROLS: [InputFocus; 2] = [InputFocus::Filter, InputFocus::Pagination];
24
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub enum UserTab {
27    Permissions,
28    Groups,
29    Tags,
30    SecurityCredentials,
31    LastAccessed,
32}
33
34impl CyclicEnum for UserTab {
35    const ALL: &'static [Self] = &[
36        Self::Permissions,
37        Self::Groups,
38        Self::Tags,
39        Self::SecurityCredentials,
40        Self::LastAccessed,
41    ];
42}
43
44impl UserTab {
45    pub fn name(&self) -> &'static str {
46        match self {
47            UserTab::Permissions => "Permissions",
48            UserTab::Groups => "Groups",
49            UserTab::Tags => "Tags",
50            UserTab::SecurityCredentials => "Security Credentials",
51            UserTab::LastAccessed => "Last Accessed",
52        }
53    }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq)]
57pub enum RoleTab {
58    Permissions,
59    TrustRelationships,
60    Tags,
61    LastAccessed,
62    RevokeSessions,
63}
64
65impl CyclicEnum for RoleTab {
66    const ALL: &'static [Self] = &[
67        Self::Permissions,
68        Self::TrustRelationships,
69        Self::Tags,
70        Self::LastAccessed,
71        Self::RevokeSessions,
72    ];
73}
74
75impl RoleTab {
76    pub fn name(&self) -> &'static str {
77        match self {
78            RoleTab::Permissions => "Permissions",
79            RoleTab::TrustRelationships => "Trust relationships",
80            RoleTab::Tags => "Tags",
81            RoleTab::LastAccessed => "Last Accessed",
82            RoleTab::RevokeSessions => "Revoke sessions",
83        }
84    }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq)]
88pub enum GroupTab {
89    Users,
90    Permissions,
91    AccessAdvisor,
92}
93
94impl CyclicEnum for GroupTab {
95    const ALL: &'static [Self] = &[Self::Users, Self::Permissions, Self::AccessAdvisor];
96}
97
98impl GroupTab {
99    pub fn name(&self) -> &'static str {
100        match self {
101            GroupTab::Users => "Users",
102            GroupTab::Permissions => "Permissions",
103            GroupTab::AccessAdvisor => "Access Advisor",
104        }
105    }
106}
107
108#[derive(Debug, Clone, Copy, PartialEq)]
109pub enum AccessHistoryFilter {
110    NoFilter,
111    ServicesAccessed,
112    ServicesNotAccessed,
113}
114
115impl AccessHistoryFilter {
116    pub fn name(&self) -> &'static str {
117        match self {
118            AccessHistoryFilter::NoFilter => "No filter",
119            AccessHistoryFilter::ServicesAccessed => "Services accessed",
120            AccessHistoryFilter::ServicesNotAccessed => "Services not accessed",
121        }
122    }
123
124    pub fn next(&self) -> Self {
125        match self {
126            AccessHistoryFilter::NoFilter => AccessHistoryFilter::ServicesAccessed,
127            AccessHistoryFilter::ServicesAccessed => AccessHistoryFilter::ServicesNotAccessed,
128            AccessHistoryFilter::ServicesNotAccessed => AccessHistoryFilter::NoFilter,
129        }
130    }
131}
132
133pub struct State {
134    pub users: TableState<IamUser>,
135    pub current_user: Option<String>,
136    pub user_tab: UserTab,
137    pub roles: TableState<IamRole>,
138    pub current_role: Option<String>,
139    pub role_tab: RoleTab,
140    pub role_input_focus: InputFocus,
141    pub groups: TableState<IamGroup>,
142    pub current_group: Option<String>,
143    pub group_tab: GroupTab,
144    pub policies: TableState<Policy>,
145    pub policy_type_filter: String,
146    pub policy_input_focus: InputFocus,
147    pub current_policy: Option<String>,
148    pub policy_document: String,
149    pub policy_scroll: usize,
150    pub trust_policy_document: String,
151    pub trust_policy_scroll: usize,
152    pub tags: TableState<RoleTag>,
153    pub user_tags: TableState<UserTag>,
154    pub user_group_memberships: TableState<UserGroup>,
155    pub group_users: TableState<GroupUser>,
156    pub last_accessed_services: TableState<LastAccessedService>,
157    pub last_accessed_filter: String,
158    pub last_accessed_history_filter: AccessHistoryFilter,
159    pub revoke_sessions_scroll: usize,
160}
161
162impl Default for State {
163    fn default() -> Self {
164        Self::new()
165    }
166}
167
168impl State {
169    pub fn new() -> Self {
170        Self {
171            users: TableState::new(),
172            current_user: None,
173            user_tab: UserTab::Permissions,
174            roles: TableState::new(),
175            current_role: None,
176            role_tab: RoleTab::Permissions,
177            role_input_focus: InputFocus::Filter,
178            groups: TableState::new(),
179            current_group: None,
180            group_tab: GroupTab::Users,
181            policies: TableState::new(),
182            policy_type_filter: "All types".to_string(),
183            policy_input_focus: InputFocus::Filter,
184            current_policy: None,
185            policy_document: String::new(),
186            policy_scroll: 0,
187            trust_policy_document: String::new(),
188            trust_policy_scroll: 0,
189            tags: TableState::new(),
190            user_tags: TableState::new(),
191            user_group_memberships: TableState::new(),
192            group_users: TableState::new(),
193            last_accessed_services: TableState::new(),
194            last_accessed_filter: String::new(),
195            last_accessed_history_filter: AccessHistoryFilter::NoFilter,
196            revoke_sessions_scroll: 0,
197        }
198    }
199}
200
201pub fn render_users(frame: &mut Frame, app: &App, area: Rect) {
202    frame.render_widget(Clear, area);
203
204    if app.iam_state.current_user.is_some() {
205        render_user_detail(frame, app, area);
206    } else {
207        render_user_list(frame, app, area);
208    }
209}
210
211pub fn render_user_list(frame: &mut Frame, app: &App, area: Rect) {
212    let chunks = vertical(
213        [
214            Constraint::Length(1), // Description
215            Constraint::Length(3), // Filter
216            Constraint::Min(0),    // Table
217        ],
218        area,
219    );
220
221    // Description
222    let desc = Paragraph::new("An IAM user is an identity with long-term credentials that is used to interact with AWS in an account.")
223        .style(Style::default().fg(Color::White));
224    frame.render_widget(desc, chunks[0]);
225
226    // Filter
227    let filtered_users = crate::ui::iam::filtered_iam_users(app);
228    let filtered_count = filtered_users.len();
229    let page_size = app.iam_state.users.page_size.value();
230    crate::ui::render_search_filter(
231        frame,
232        chunks[1],
233        &app.iam_state.users.filter,
234        app.mode == Mode::FilterInput,
235        app.iam_state.users.selected,
236        filtered_count,
237        page_size,
238    );
239
240    // Table
241    let scroll_offset = app.iam_state.users.scroll_offset;
242    let page_users: Vec<_> = filtered_users
243        .iter()
244        .skip(scroll_offset)
245        .take(page_size)
246        .collect();
247
248    use crate::iam::UserColumn;
249    let mut columns: Vec<Box<dyn crate::ui::table::Column<&crate::iam::IamUser>>> = vec![];
250    for col in &app.visible_iam_columns {
251        let column = match col.as_str() {
252            "User name" => Some(UserColumn::UserName),
253            "Path" => Some(UserColumn::Path),
254            "Groups" => Some(UserColumn::Groups),
255            "Last activity" => Some(UserColumn::LastActivity),
256            "MFA" => Some(UserColumn::Mfa),
257            "Password age" => Some(UserColumn::PasswordAge),
258            "Console last sign-in" => Some(UserColumn::ConsoleLastSignIn),
259            "Access key ID" => Some(UserColumn::AccessKeyId),
260            "Active key age" => Some(UserColumn::ActiveKeyAge),
261            "Access key last used" => Some(UserColumn::AccessKeyLastUsed),
262            "ARN" => Some(UserColumn::Arn),
263            "Creation time" => Some(UserColumn::CreationTime),
264            "Console access" => Some(UserColumn::ConsoleAccess),
265            "Signing certs" => Some(UserColumn::SigningCerts),
266            _ => None,
267        };
268        if let Some(c) = column {
269            columns.push(c.to_column());
270        }
271    }
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 = crate::ui::table::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!(" Users ({}) ", filtered_count),
289        area: chunks[2],
290        get_expanded_content: Some(Box::new(|user: &&crate::iam::IamUser| {
291            crate::ui::table::expanded_from_columns(&columns, user)
292        })),
293        is_active: app.mode != Mode::FilterInput,
294    };
295
296    crate::ui::table::render_table(frame, config);
297}
298
299pub fn render_roles(frame: &mut Frame, app: &App, area: Rect) {
300    frame.render_widget(Clear, area);
301
302    if app.view_mode == ViewMode::PolicyView {
303        render_policy_view(frame, app, area);
304    } else if app.iam_state.current_role.is_some() {
305        render_role_detail(frame, app, area);
306    } else {
307        render_role_list(frame, app, area);
308    }
309}
310
311pub fn render_user_groups(frame: &mut Frame, app: &App, area: Rect) {
312    frame.render_widget(Clear, area);
313
314    if app.iam_state.current_group.is_some() {
315        render_group_detail(frame, app, area);
316    } else {
317        render_group_list(frame, app, area);
318    }
319}
320
321pub fn render_group_detail(frame: &mut Frame, app: &App, area: Rect) {
322    let chunks = vertical(
323        [
324            Constraint::Length(1), // Group name
325            Constraint::Length(5), // Summary (3 lines + 2 borders)
326            Constraint::Length(1), // Tabs
327            Constraint::Min(0),    // Content
328        ],
329        area,
330    );
331
332    if let Some(group_name) = &app.iam_state.current_group {
333        let label = Paragraph::new(group_name.as_str()).style(
334            Style::default()
335                .fg(Color::Yellow)
336                .add_modifier(Modifier::BOLD),
337        );
338        frame.render_widget(label, chunks[0]);
339
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            crate::ui::render_summary(
349                frame,
350                chunks[1],
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        crate::ui::render_tabs(
368            frame,
369            chunks[2],
370            &[
371                ("Users", GroupTab::Users),
372                ("Permissions", GroupTab::Permissions),
373                ("Access Advisor", GroupTab::AccessAdvisor),
374            ],
375            &app.iam_state.group_tab,
376        );
377
378        // Content area based on selected tab
379        match app.iam_state.group_tab {
380            GroupTab::Users => {
381                render_group_users_tab(frame, app, chunks[3]);
382            }
383            GroupTab::Permissions => {
384                render_permissions_section(
385                    frame,
386                    chunks[3],
387                    "You can attach up to 10 managed policies.",
388                    |f, area| render_policies_table(f, app, area),
389                );
390            }
391            GroupTab::AccessAdvisor => {
392                render_group_access_advisor_tab(frame, app, chunks[3]);
393            }
394        }
395    }
396}
397
398pub fn render_group_users_tab(frame: &mut Frame, app: &App, area: Rect) {
399    let chunks = vertical(
400        [
401            Constraint::Length(1), // Description
402            Constraint::Min(0),    // Table
403        ],
404        area,
405    );
406
407    frame.render_widget(
408        Paragraph::new("An IAM user is an entity that you create in AWS to represent the person or application that uses it to interact with AWS."),
409        chunks[0],
410    );
411
412    render_group_users_table(frame, app, chunks[1]);
413}
414
415pub fn render_group_users_table(frame: &mut Frame, app: &App, area: Rect) {
416    let chunks = vertical(
417        [
418            Constraint::Length(3), // Filter with pagination
419            Constraint::Min(0),    // Table
420        ],
421        area,
422    );
423
424    let page_size = app.iam_state.group_users.page_size.value();
425    let filtered_users: Vec<_> = app
426        .iam_state
427        .group_users
428        .items
429        .iter()
430        .filter(|u| {
431            if app.iam_state.group_users.filter.is_empty() {
432                true
433            } else {
434                u.user_name
435                    .to_lowercase()
436                    .contains(&app.iam_state.group_users.filter.to_lowercase())
437            }
438        })
439        .collect();
440
441    let filtered_count = filtered_users.len();
442    let total_pages = filtered_count.div_ceil(page_size);
443    let current_page = app.iam_state.group_users.selected / page_size;
444    let pagination = render_pagination_text(current_page, total_pages);
445
446    crate::ui::filter::render_simple_filter(
447        frame,
448        chunks[0],
449        crate::ui::filter::SimpleFilterConfig {
450            filter_text: &app.iam_state.group_users.filter,
451            placeholder: "Search",
452            pagination: &pagination,
453            mode: app.mode,
454            is_input_focused: true,
455            is_pagination_focused: false,
456        },
457    );
458
459    let scroll_offset = app.iam_state.group_users.scroll_offset;
460    let page_users: Vec<&crate::iam::GroupUser> = filtered_users
461        .into_iter()
462        .skip(scroll_offset)
463        .take(page_size)
464        .collect();
465
466    use crate::iam::GroupUserColumn;
467    let columns: Vec<Box<dyn crate::ui::table::Column<crate::iam::GroupUser>>> = vec![
468        GroupUserColumn::UserName.to_column(),
469        GroupUserColumn::Groups.to_column(),
470        GroupUserColumn::LastActivity.to_column(),
471        GroupUserColumn::CreationTime.to_column(),
472    ];
473
474    let expanded_index = app.iam_state.group_users.expanded_item.and_then(|idx| {
475        if idx >= scroll_offset && idx < scroll_offset + page_size {
476            Some(idx - scroll_offset)
477        } else {
478            None
479        }
480    });
481
482    let config = crate::ui::table::TableConfig {
483        items: page_users,
484        selected_index: app.iam_state.group_users.selected - scroll_offset,
485        expanded_index,
486        columns: &columns,
487        sort_column: "User name",
488        sort_direction: SortDirection::Asc,
489        title: format!(" Users ({}) ", app.iam_state.group_users.items.len()),
490        area: chunks[1],
491        is_active: app.mode != Mode::ColumnSelector,
492        get_expanded_content: Some(Box::new(|user: &crate::iam::GroupUser| {
493            crate::ui::table::plain_expanded_content(format!(
494                "User name: {}\nGroups: {}\nLast activity: {}\nCreation time: {}",
495                user.user_name, user.groups, user.last_activity, user.creation_time
496            ))
497        })),
498    };
499
500    crate::ui::table::render_table(frame, config);
501}
502
503pub fn render_group_access_advisor_tab(frame: &mut Frame, app: &App, area: Rect) {
504    let chunks = vertical(
505        [
506            Constraint::Length(1), // Description
507            Constraint::Min(0),    // Table with filters
508        ],
509        area,
510    );
511
512    frame.render_widget(
513        Paragraph::new(
514            "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."
515        ),
516        chunks[0],
517    );
518
519    render_last_accessed_table(frame, app, chunks[1]);
520}
521
522pub fn render_group_list(frame: &mut Frame, app: &App, area: Rect) {
523    let chunks = vertical(
524        [
525            Constraint::Length(1), // Description
526            Constraint::Length(3), // Filter
527            Constraint::Min(0),    // Table
528        ],
529        area,
530    );
531
532    let desc = Paragraph::new("A user group is a collection of IAM users. Use groups to specify permissions for a collection of users.")
533        .style(Style::default().fg(Color::White));
534    frame.render_widget(desc, chunks[0]);
535
536    let cursor = get_cursor(app.mode == Mode::FilterInput);
537    let page_size = app.iam_state.groups.page_size.value();
538    let filtered_groups: Vec<_> = app
539        .iam_state
540        .groups
541        .items
542        .iter()
543        .filter(|g| {
544            if app.iam_state.groups.filter.is_empty() {
545                true
546            } else {
547                g.group_name
548                    .to_lowercase()
549                    .contains(&app.iam_state.groups.filter.to_lowercase())
550            }
551        })
552        .collect();
553
554    let filtered_count = filtered_groups.len();
555    let total_pages = filtered_count.div_ceil(page_size);
556    let current_page = app.iam_state.groups.selected / page_size;
557    let pagination = render_pagination_text(current_page, total_pages);
558
559    let filter_width = (chunks[1].width as usize).saturating_sub(4);
560    let pagination_len = pagination.len();
561    let available_space = filter_width.saturating_sub(pagination_len + 1);
562
563    let mut first_line_spans = vec![];
564    if app.iam_state.groups.filter.is_empty() && app.mode != Mode::FilterInput {
565        first_line_spans.push(Span::styled("Search", Style::default().fg(Color::DarkGray)));
566    } else {
567        first_line_spans.push(Span::raw(&app.iam_state.groups.filter));
568    }
569    if app.mode == Mode::FilterInput {
570        first_line_spans.push(Span::raw(cursor));
571    }
572
573    let content_len = if app.iam_state.groups.filter.is_empty() && app.mode != Mode::FilterInput {
574        6
575    } else {
576        app.iam_state.groups.filter.len() + cursor.len()
577    };
578
579    if content_len < available_space {
580        first_line_spans.push(Span::raw(
581            " ".repeat(available_space.saturating_sub(content_len)),
582        ));
583    }
584    first_line_spans.push(Span::styled(
585        pagination,
586        Style::default().fg(Color::DarkGray),
587    ));
588
589    frame.render_widget(
590        Paragraph::new(Line::from(first_line_spans)).block(
591            Block::default()
592                .title(SEARCH_ICON)
593                .borders(Borders::ALL)
594                .border_style(if app.mode == Mode::FilterInput {
595                    Style::default().fg(Color::Yellow)
596                } else {
597                    Style::default()
598                }),
599        ),
600        chunks[1],
601    );
602
603    let scroll_offset = app.iam_state.groups.scroll_offset;
604    let page_groups: Vec<&crate::iam::IamGroup> = filtered_groups
605        .into_iter()
606        .skip(scroll_offset)
607        .take(page_size)
608        .collect();
609
610    use crate::iam::GroupColumn;
611    let mut columns: Vec<Box<dyn crate::ui::table::Column<crate::iam::IamGroup>>> = vec![];
612    for col_name in &app.visible_group_columns {
613        let column = match col_name.as_str() {
614            "Group name" => Some(GroupColumn::GroupName),
615            "Path" => Some(GroupColumn::Path),
616            "Users" => Some(GroupColumn::Users),
617            "Permissions" => Some(GroupColumn::Permissions),
618            "Creation time" => Some(GroupColumn::CreationTime),
619            _ => None,
620        };
621        if let Some(c) = column {
622            columns.push(c.to_column());
623        }
624    }
625
626    let expanded_index = app.iam_state.groups.expanded_item.and_then(|idx| {
627        if idx >= scroll_offset && idx < scroll_offset + page_size {
628            Some(idx - scroll_offset)
629        } else {
630            None
631        }
632    });
633
634    let config = crate::ui::table::TableConfig {
635        items: page_groups,
636        selected_index: app.iam_state.groups.selected - scroll_offset,
637        expanded_index,
638        columns: &columns,
639        sort_column: "Group name",
640        sort_direction: SortDirection::Asc,
641        title: format!(" User groups ({}) ", app.iam_state.groups.items.len()),
642        area: chunks[2],
643        is_active: app.mode != Mode::ColumnSelector,
644        get_expanded_content: Some(Box::new(|group: &crate::iam::IamGroup| {
645            crate::ui::table::expanded_from_columns(&columns, group)
646        })),
647    };
648
649    crate::ui::table::render_table(frame, config);
650}
651
652pub fn render_role_list(frame: &mut Frame, app: &App, area: Rect) {
653    let chunks = vertical(
654        [
655            Constraint::Length(1), // Description
656            Constraint::Length(3), // Filter
657            Constraint::Min(0),    // Table
658        ],
659        area,
660    );
661
662    let desc = Paragraph::new("An IAM role is an identity you can create that has specific permissions with credentials that are valid for short durations. Roles can be assumed by entities that you trust.")
663        .style(Style::default().fg(Color::White));
664    frame.render_widget(desc, chunks[0]);
665
666    // Filter with CFN pattern
667    let page_size = app.iam_state.roles.page_size.value();
668    let filtered_count = crate::ui::iam::filtered_iam_roles(app).len();
669    let total_pages = if filtered_count == 0 {
670        1
671    } else {
672        filtered_count.div_ceil(page_size)
673    };
674    let current_page = if filtered_count == 0 {
675        0
676    } else {
677        app.iam_state.roles.selected / page_size
678    };
679    let pagination = render_pagination_text(current_page, total_pages);
680
681    crate::ui::filter::render_simple_filter(
682        frame,
683        chunks[1],
684        crate::ui::filter::SimpleFilterConfig {
685            filter_text: &app.iam_state.roles.filter,
686            placeholder: "Search",
687            pagination: &pagination,
688            mode: app.mode,
689            is_input_focused: app.iam_state.role_input_focus == InputFocus::Filter,
690            is_pagination_focused: app.iam_state.role_input_focus == InputFocus::Pagination,
691        },
692    );
693
694    // Table
695    let scroll_offset = app.iam_state.roles.scroll_offset;
696    let page_roles: Vec<_> = filtered_iam_roles(app)
697        .into_iter()
698        .skip(scroll_offset)
699        .take(page_size)
700        .collect();
701
702    use crate::iam::RoleColumn;
703    let mut columns: Vec<Box<dyn crate::ui::table::Column<crate::iam::IamRole>>> = vec![];
704    for col in &app.visible_role_columns {
705        let column = match col.as_str() {
706            "Role name" => Some(RoleColumn::RoleName),
707            "Path" => Some(RoleColumn::Path),
708            "Description" => Some(RoleColumn::Description),
709            "Trusted entities" => Some(RoleColumn::TrustedEntities),
710            "Creation time" => Some(RoleColumn::CreationTime),
711            "ARN" => Some(RoleColumn::Arn),
712            "Max CLI/API session" => Some(RoleColumn::MaxSessionDuration),
713            "Last activity" => Some(RoleColumn::LastActivity),
714            _ => None,
715        };
716        if let Some(c) = column {
717            columns.push(c.to_column());
718        }
719    }
720
721    let expanded_index = app.iam_state.roles.expanded_item.and_then(|idx| {
722        if idx >= scroll_offset && idx < scroll_offset + page_size {
723            Some(idx - scroll_offset)
724        } else {
725            None
726        }
727    });
728
729    let config = crate::ui::table::TableConfig {
730        items: page_roles,
731        selected_index: app.iam_state.roles.selected % page_size,
732        expanded_index,
733        columns: &columns,
734        sort_column: "Role name",
735        sort_direction: SortDirection::Asc,
736        title: format!(" Roles ({}) ", filtered_count),
737        area: chunks[2],
738        is_active: app.mode != Mode::ColumnSelector,
739        get_expanded_content: Some(Box::new(|role: &crate::iam::IamRole| {
740            crate::ui::table::expanded_from_columns(&columns, role)
741        })),
742    };
743
744    crate::ui::table::render_table(frame, config);
745}
746
747pub fn render_role_detail(frame: &mut Frame, app: &App, area: Rect) {
748    frame.render_widget(Clear, area);
749
750    let chunks = vertical(
751        [
752            Constraint::Length(1), // Role name
753            Constraint::Length(7), // Summary (5 lines + 2 borders)
754            Constraint::Length(1), // Tabs
755            Constraint::Min(0),    // Content
756        ],
757        area,
758    );
759
760    // Role name label
761    if let Some(role_name) = &app.iam_state.current_role {
762        let label = Paragraph::new(role_name.as_str()).style(
763            Style::default()
764                .fg(Color::Yellow)
765                .add_modifier(Modifier::BOLD),
766        );
767        frame.render_widget(label, chunks[0]);
768    }
769
770    // Summary section
771    if let Some(role_name) = &app.iam_state.current_role {
772        if let Some(role) = app
773            .iam_state
774            .roles
775            .items
776            .iter()
777            .find(|r| r.role_name == *role_name)
778        {
779            let formatted_duration = role
780                .max_session_duration
781                .split_whitespace()
782                .next()
783                .and_then(|s| s.parse::<u64>().ok())
784                .map(crate::ui::format_duration)
785                .unwrap_or_else(|| role.max_session_duration.clone());
786
787            crate::ui::render_summary(
788                frame,
789                chunks[1],
790                " Summary ",
791                &[
792                    ("ARN: ", role.arn.clone()),
793                    ("Trusted entities: ", role.trusted_entities.clone()),
794                    ("Max session duration: ", formatted_duration),
795                    ("Created: ", role.creation_time.clone()),
796                    ("Description: ", role.description.clone()),
797                ],
798            );
799        }
800    }
801
802    // Tabs
803    crate::ui::render_tabs(
804        frame,
805        chunks[2],
806        &[
807            (RoleTab::Permissions.name(), RoleTab::Permissions),
808            (
809                RoleTab::TrustRelationships.name(),
810                RoleTab::TrustRelationships,
811            ),
812            (RoleTab::Tags.name(), RoleTab::Tags),
813            (RoleTab::LastAccessed.name(), RoleTab::LastAccessed),
814            (RoleTab::RevokeSessions.name(), RoleTab::RevokeSessions),
815        ],
816        &app.iam_state.role_tab,
817    );
818
819    // Content based on selected tab
820    match app.iam_state.role_tab {
821        RoleTab::Permissions => {
822            render_permissions_section(
823                frame,
824                chunks[3],
825                "You can attach up to 10 managed policies.",
826                |f, area| render_policies_table(f, app, area),
827            );
828        }
829        RoleTab::TrustRelationships => {
830            let chunks_inner = vertical(
831                [
832                    Constraint::Length(1),
833                    Constraint::Length(1),
834                    Constraint::Min(0),
835                ],
836                chunks[3],
837            );
838
839            frame.render_widget(
840                Paragraph::new("Trusted entities").style(Style::default().fg(Color::Cyan).bold()),
841                chunks_inner[0],
842            );
843
844            frame.render_widget(
845                Paragraph::new("Entities that can assume this role under specified conditions."),
846                chunks_inner[1],
847            );
848
849            render_json_highlighted(
850                frame,
851                chunks_inner[2],
852                &app.iam_state.trust_policy_document,
853                app.iam_state.trust_policy_scroll,
854                " Trust Policy ",
855            );
856        }
857        RoleTab::Tags => {
858            render_tags_section(frame, chunks[3], |f, area| render_tags_table(f, app, area));
859        }
860        RoleTab::RevokeSessions => {
861            let chunks_inner = vertical(
862                [
863                    Constraint::Length(1),
864                    Constraint::Length(2),
865                    Constraint::Length(1),
866                    Constraint::Min(0),
867                ],
868                chunks[3],
869            );
870
871            frame.render_widget(
872                Paragraph::new("Revoke all active sessions")
873                    .style(Style::default().fg(Color::Cyan).bold()),
874                chunks_inner[0],
875            );
876
877            frame.render_widget(
878                Paragraph::new(
879                    "If you choose Revoke active sessions, IAM attaches an inline policy named AWSRevokeOlderSessions to this role. This policy denies access to all currently active sessions for this role. You can continue to create new sessions based on this role. If you need to undo this action later, you can remove the inline policy."
880                ).wrap(ratatui::widgets::Wrap { trim: true }),
881                chunks_inner[1],
882            );
883
884            frame.render_widget(
885                Paragraph::new("Here is an example of the AWSRevokeOlderSessions policy that is created after you choose Revoke active sessions:"),
886                chunks_inner[2],
887            );
888
889            let example_policy = r#"{
890    "Version": "2012-10-17",
891    "Statement": [
892        {
893            "Effect": "Deny",
894            "Action": [
895                "*"
896            ],
897            "Resource": [
898                "*"
899            ],
900            "Condition": {
901                "DateLessThan": {
902                    "aws:TokenIssueTime": "[policy creation time]"
903                }
904            }
905        }
906    ]
907}"#;
908
909            render_json_highlighted(
910                frame,
911                chunks_inner[3],
912                example_policy,
913                app.iam_state.revoke_sessions_scroll,
914                " Example Policy ",
915            );
916        }
917        RoleTab::LastAccessed => {
918            render_last_accessed_section(
919                frame,
920                chunks[3],
921                "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.",
922                "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.",
923                |f, area| render_last_accessed_table(f, app, area),
924            );
925        }
926    }
927}
928
929pub fn render_user_detail(frame: &mut Frame, app: &App, area: Rect) {
930    frame.render_widget(Clear, area);
931
932    let chunks = vertical(
933        [
934            Constraint::Length(1), // User name
935            Constraint::Length(7), // Summary (5 lines + 2 borders)
936            Constraint::Length(1), // Tabs
937            Constraint::Min(0),    // Content
938        ],
939        area,
940    );
941
942    // User name label
943    if let Some(user_name) = &app.iam_state.current_user {
944        let label = Paragraph::new(user_name.as_str()).style(
945            Style::default()
946                .fg(Color::Yellow)
947                .add_modifier(Modifier::BOLD),
948        );
949        frame.render_widget(label, chunks[0]);
950    }
951
952    // Summary section
953    if let Some(user_name) = &app.iam_state.current_user {
954        if let Some(user) = app
955            .iam_state
956            .users
957            .items
958            .iter()
959            .find(|u| u.user_name == *user_name)
960        {
961            let summary_block = Block::default()
962                .title(" Summary ")
963                .borders(Borders::ALL)
964                .border_type(BorderType::Plain)
965                .border_style(active_border());
966
967            let summary_inner = summary_block.inner(chunks[1]);
968            frame.render_widget(summary_block, chunks[1]);
969
970            let summary_lines = vec![
971                labeled_field("ARN", &user.arn),
972                labeled_field("Console access", &user.console_access),
973                labeled_field("Access key", &user.access_key_id),
974                labeled_field("Created", &user.creation_time),
975                labeled_field("Last console sign-in", &user.console_last_sign_in),
976            ];
977
978            let summary_paragraph = Paragraph::new(summary_lines);
979            frame.render_widget(summary_paragraph, summary_inner);
980        }
981    }
982
983    // Tabs
984    crate::ui::render_tabs(
985        frame,
986        chunks[2],
987        &[
988            ("Permissions", UserTab::Permissions),
989            ("Groups", UserTab::Groups),
990            ("Tags", UserTab::Tags),
991            ("Security Credentials", UserTab::SecurityCredentials),
992            ("Last Accessed", UserTab::LastAccessed),
993        ],
994        &app.iam_state.user_tab,
995    );
996
997    // Content area - Permissions tab
998    if app.iam_state.user_tab == UserTab::Permissions {
999        render_permissions_tab(frame, app, chunks[3]);
1000    } else if app.iam_state.user_tab == UserTab::Groups {
1001        render_user_groups_tab(frame, app, chunks[3]);
1002    } else if app.iam_state.user_tab == UserTab::Tags {
1003        render_tags_section(frame, chunks[3], |f, area| {
1004            render_user_tags_table(f, app, area)
1005        });
1006    } else if app.iam_state.user_tab == UserTab::LastAccessed {
1007        render_user_last_accessed_tab(frame, app, chunks[3]);
1008    }
1009}
1010
1011pub fn render_permissions_tab(frame: &mut Frame, app: &App, area: Rect) {
1012    render_permissions_section(
1013        frame,
1014        area,
1015        "Permissions are defined by policies attached to the user directly or through groups.",
1016        |f, area| render_policies_table(f, app, area),
1017    );
1018}
1019
1020pub fn render_policy_view(frame: &mut Frame, app: &App, area: Rect) {
1021    frame.render_widget(Clear, area);
1022
1023    let chunks = vertical([Constraint::Length(1), Constraint::Min(0)], area);
1024
1025    let policy_name = app.iam_state.current_policy.as_deref().unwrap_or("");
1026    frame.render_widget(
1027        Paragraph::new(policy_name).style(Style::default().fg(Color::Cyan).bold()),
1028        chunks[0],
1029    );
1030
1031    render_json_highlighted(
1032        frame,
1033        chunks[1],
1034        &app.iam_state.policy_document,
1035        app.iam_state.policy_scroll,
1036        " Policy Document ",
1037    );
1038}
1039
1040pub fn render_policies_table(frame: &mut Frame, app: &App, area: Rect) {
1041    let chunks = vertical(
1042        [
1043            Constraint::Length(3), // Filter with dropdown and pagination
1044            Constraint::Min(0),    // Table
1045        ],
1046        area,
1047    );
1048
1049    // Filter
1050    let cursor = get_cursor(app.mode == Mode::FilterInput);
1051    let page_size = app.iam_state.policies.page_size.value();
1052    let filtered_policies: Vec<_> = app
1053        .iam_state
1054        .policies
1055        .items
1056        .iter()
1057        .filter(|p| {
1058            let matches_filter = if app.iam_state.policies.filter.is_empty() {
1059                true
1060            } else {
1061                p.policy_name
1062                    .to_lowercase()
1063                    .contains(&app.iam_state.policies.filter.to_lowercase())
1064            };
1065            let matches_type = if app.iam_state.policy_type_filter == "All types" {
1066                true
1067            } else {
1068                p.policy_type == app.iam_state.policy_type_filter
1069            };
1070            matches_filter && matches_type
1071        })
1072        .collect();
1073
1074    let filtered_count = filtered_policies.len();
1075    let total_pages = filtered_count.div_ceil(page_size);
1076    let current_page = app.iam_state.policies.selected / page_size;
1077    let pagination = render_pagination_text(current_page, total_pages);
1078    let dropdown = format!("Type: {}", app.iam_state.policy_type_filter);
1079
1080    let filter_width = (chunks[0].width as usize).saturating_sub(4);
1081    let right_content = format!("{} ⋮ {}", dropdown, pagination);
1082    let right_len = right_content.len();
1083    let available_space = filter_width.saturating_sub(right_len + 1);
1084
1085    let mut first_line_spans = vec![];
1086    if app.iam_state.policies.filter.is_empty() && app.mode != Mode::FilterInput {
1087        first_line_spans.push(Span::styled("Search", Style::default().fg(Color::DarkGray)));
1088    } else {
1089        let display_text = if app.iam_state.policies.filter.len() > available_space {
1090            format!(
1091                "...{}",
1092                &app.iam_state.policies.filter
1093                    [app.iam_state.policies.filter.len() - available_space + 3..]
1094            )
1095        } else {
1096            app.iam_state.policies.filter.clone()
1097        };
1098        first_line_spans.push(Span::raw(display_text));
1099    }
1100    if app.mode == Mode::FilterInput {
1101        first_line_spans.push(Span::raw(cursor));
1102    }
1103
1104    first_line_spans.push(Span::raw(
1105        " ".repeat(
1106            available_space.saturating_sub(
1107                first_line_spans
1108                    .iter()
1109                    .map(|s| s.content.len())
1110                    .sum::<usize>(),
1111            ),
1112        ),
1113    ));
1114    first_line_spans.push(Span::styled(
1115        right_content,
1116        Style::default().fg(Color::DarkGray),
1117    ));
1118
1119    frame.render_widget(
1120        Paragraph::new(Line::from(first_line_spans)).block(
1121            Block::default()
1122                .title(SEARCH_ICON)
1123                .borders(Borders::ALL)
1124                .border_style(if app.mode == Mode::FilterInput {
1125                    Style::default().fg(Color::Yellow)
1126                } else {
1127                    Style::default()
1128                }),
1129        ),
1130        chunks[0],
1131    );
1132
1133    // Table
1134    let scroll_offset = app.iam_state.policies.scroll_offset;
1135    let page_policies: Vec<&crate::iam::Policy> = filtered_policies
1136        .into_iter()
1137        .skip(scroll_offset)
1138        .take(page_size)
1139        .collect();
1140
1141    // Define columns
1142    struct PolicyNameColumn;
1143    impl crate::ui::table::Column<crate::iam::Policy> for PolicyNameColumn {
1144        fn name(&self) -> &str {
1145            "Policy name"
1146        }
1147        fn width(&self) -> u16 {
1148            30
1149        }
1150        fn render(&self, item: &crate::iam::Policy) -> (String, Style) {
1151            (item.policy_name.clone(), Style::default())
1152        }
1153    }
1154
1155    struct TypeColumn;
1156    impl crate::ui::table::Column<crate::iam::Policy> for TypeColumn {
1157        fn name(&self) -> &str {
1158            "Type"
1159        }
1160        fn width(&self) -> u16 {
1161            15
1162        }
1163        fn render(&self, item: &crate::iam::Policy) -> (String, Style) {
1164            (item.policy_type.clone(), Style::default())
1165        }
1166    }
1167
1168    struct AttachedViaColumn;
1169    impl crate::ui::table::Column<crate::iam::Policy> for AttachedViaColumn {
1170        fn name(&self) -> &str {
1171            "Attached via"
1172        }
1173        fn width(&self) -> u16 {
1174            20
1175        }
1176        fn render(&self, item: &crate::iam::Policy) -> (String, Style) {
1177            (item.attached_via.clone(), Style::default())
1178        }
1179    }
1180
1181    struct AttachedEntitiesColumn;
1182    impl crate::ui::table::Column<crate::iam::Policy> for AttachedEntitiesColumn {
1183        fn name(&self) -> &str {
1184            "Attached entities"
1185        }
1186        fn width(&self) -> u16 {
1187            20
1188        }
1189        fn render(&self, item: &crate::iam::Policy) -> (String, Style) {
1190            (item.attached_entities.clone(), Style::default())
1191        }
1192    }
1193
1194    struct DescriptionColumn;
1195    impl crate::ui::table::Column<crate::iam::Policy> for DescriptionColumn {
1196        fn name(&self) -> &str {
1197            "Description"
1198        }
1199        fn width(&self) -> u16 {
1200            40
1201        }
1202        fn render(&self, item: &crate::iam::Policy) -> (String, Style) {
1203            (item.description.clone(), Style::default())
1204        }
1205    }
1206
1207    struct CreationTimeColumn;
1208    impl crate::ui::table::Column<crate::iam::Policy> for CreationTimeColumn {
1209        fn name(&self) -> &str {
1210            "Creation time"
1211        }
1212        fn width(&self) -> u16 {
1213            30
1214        }
1215        fn render(&self, item: &crate::iam::Policy) -> (String, Style) {
1216            (item.creation_time.clone(), Style::default())
1217        }
1218    }
1219
1220    struct EditedTimeColumn;
1221    impl crate::ui::table::Column<crate::iam::Policy> for EditedTimeColumn {
1222        fn name(&self) -> &str {
1223            "Edited time"
1224        }
1225        fn width(&self) -> u16 {
1226            30
1227        }
1228        fn render(&self, item: &crate::iam::Policy) -> (String, Style) {
1229            (item.edited_time.clone(), Style::default())
1230        }
1231    }
1232
1233    let mut columns: Vec<Box<dyn crate::ui::table::Column<crate::iam::Policy>>> = vec![];
1234    for col in &app.visible_policy_columns {
1235        match col.as_str() {
1236            "Policy name" => columns.push(Box::new(PolicyNameColumn)),
1237            "Type" => columns.push(Box::new(TypeColumn)),
1238            "Attached via" => columns.push(Box::new(AttachedViaColumn)),
1239            "Attached entities" => columns.push(Box::new(AttachedEntitiesColumn)),
1240            "Description" => columns.push(Box::new(DescriptionColumn)),
1241            "Creation time" => columns.push(Box::new(CreationTimeColumn)),
1242            "Edited time" => columns.push(Box::new(EditedTimeColumn)),
1243            _ => {}
1244        }
1245    }
1246
1247    let expanded_index = app.iam_state.policies.expanded_item.and_then(|idx| {
1248        if idx >= scroll_offset && idx < scroll_offset + page_size {
1249            Some(idx - scroll_offset)
1250        } else {
1251            None
1252        }
1253    });
1254
1255    let config = crate::ui::table::TableConfig {
1256        items: page_policies,
1257        selected_index: app.iam_state.policies.selected - scroll_offset,
1258        expanded_index,
1259        columns: &columns,
1260        sort_column: "Policy name",
1261        sort_direction: SortDirection::Asc,
1262        title: format!(" Permissions policies ({}) ", filtered_count),
1263        area: chunks[1],
1264        is_active: app.mode != Mode::ColumnSelector,
1265        get_expanded_content: Some(Box::new(|policy: &crate::iam::Policy| {
1266            crate::ui::table::expanded_from_columns(&columns, policy)
1267        })),
1268    };
1269
1270    crate::ui::table::render_table(frame, config);
1271}
1272
1273pub fn render_tags_table(frame: &mut Frame, app: &App, area: Rect) {
1274    let chunks = vertical(
1275        [
1276            Constraint::Length(3), // Filter with pagination
1277            Constraint::Min(0),    // Table
1278        ],
1279        area,
1280    );
1281
1282    // Filter
1283    let cursor = get_cursor(app.mode == Mode::FilterInput);
1284    let page_size = app.iam_state.tags.page_size.value();
1285    let filtered_tags: Vec<_> = app
1286        .iam_state
1287        .tags
1288        .items
1289        .iter()
1290        .filter(|t| {
1291            if app.iam_state.tags.filter.is_empty() {
1292                true
1293            } else {
1294                t.key
1295                    .to_lowercase()
1296                    .contains(&app.iam_state.tags.filter.to_lowercase())
1297                    || t.value
1298                        .to_lowercase()
1299                        .contains(&app.iam_state.tags.filter.to_lowercase())
1300            }
1301        })
1302        .collect();
1303
1304    let filtered_count = filtered_tags.len();
1305    let total_pages = filtered_count.div_ceil(page_size);
1306    let current_page = app.iam_state.tags.selected / page_size;
1307    let pagination = render_pagination_text(current_page, total_pages);
1308
1309    let filter_width = (chunks[0].width as usize).saturating_sub(4);
1310    let pagination_len = pagination.len();
1311    let available_space = filter_width.saturating_sub(pagination_len + 1);
1312
1313    let mut first_line_spans = vec![];
1314    if app.iam_state.tags.filter.is_empty() && app.mode != Mode::FilterInput {
1315        first_line_spans.push(Span::styled("Search", Style::default().fg(Color::DarkGray)));
1316    } else {
1317        first_line_spans.push(Span::raw(&app.iam_state.tags.filter));
1318    }
1319    if app.mode == Mode::FilterInput {
1320        first_line_spans.push(Span::raw(cursor));
1321    }
1322
1323    let content_len = if app.iam_state.tags.filter.is_empty() && app.mode != Mode::FilterInput {
1324        6
1325    } else {
1326        app.iam_state.tags.filter.len() + cursor.len()
1327    };
1328
1329    if content_len < available_space {
1330        first_line_spans.push(Span::raw(
1331            " ".repeat(available_space.saturating_sub(content_len)),
1332        ));
1333    }
1334    first_line_spans.push(Span::styled(
1335        pagination,
1336        Style::default().fg(Color::DarkGray),
1337    ));
1338
1339    frame.render_widget(
1340        Paragraph::new(Line::from(first_line_spans)).block(
1341            Block::default()
1342                .title(SEARCH_ICON)
1343                .borders(Borders::ALL)
1344                .border_style(if app.mode == Mode::FilterInput {
1345                    Style::default().fg(Color::Yellow)
1346                } else {
1347                    Style::default()
1348                }),
1349        ),
1350        chunks[0],
1351    );
1352
1353    // Table using common render_table
1354    let scroll_offset = app.iam_state.tags.scroll_offset;
1355    let page_tags: Vec<&crate::iam::RoleTag> = filtered_tags
1356        .into_iter()
1357        .skip(scroll_offset)
1358        .take(page_size)
1359        .collect();
1360
1361    struct KeyColumn;
1362    impl crate::ui::table::Column<crate::iam::RoleTag> for KeyColumn {
1363        fn name(&self) -> &str {
1364            "Key"
1365        }
1366        fn width(&self) -> u16 {
1367            30
1368        }
1369        fn render(&self, item: &crate::iam::RoleTag) -> (String, Style) {
1370            (item.key.clone(), Style::default())
1371        }
1372    }
1373
1374    struct ValueColumn;
1375    impl crate::ui::table::Column<crate::iam::RoleTag> for ValueColumn {
1376        fn name(&self) -> &str {
1377            "Value"
1378        }
1379        fn width(&self) -> u16 {
1380            70
1381        }
1382        fn render(&self, item: &crate::iam::RoleTag) -> (String, Style) {
1383            (item.value.clone(), Style::default())
1384        }
1385    }
1386
1387    let columns: Vec<Box<dyn crate::ui::table::Column<crate::iam::RoleTag>>> =
1388        vec![Box::new(KeyColumn), Box::new(ValueColumn)];
1389
1390    let expanded_index = app.iam_state.tags.expanded_item.and_then(|idx| {
1391        if idx >= scroll_offset && idx < scroll_offset + page_size {
1392            Some(idx - scroll_offset)
1393        } else {
1394            None
1395        }
1396    });
1397
1398    let config = crate::ui::table::TableConfig {
1399        items: page_tags,
1400        selected_index: app.iam_state.tags.selected - scroll_offset,
1401        expanded_index,
1402        columns: &columns,
1403        sort_column: "",
1404        sort_direction: SortDirection::Asc,
1405        title: format!(" Tags ({}) ", app.iam_state.tags.items.len()),
1406        area: chunks[1],
1407        is_active: true,
1408        get_expanded_content: Some(Box::new(|tag: &crate::iam::RoleTag| {
1409            crate::ui::table::plain_expanded_content(format!(
1410                "Key: {}\nValue: {}",
1411                tag.key, tag.value
1412            ))
1413        })),
1414    };
1415
1416    crate::ui::table::render_table(frame, config);
1417}
1418
1419pub fn render_user_groups_tab(frame: &mut Frame, app: &App, area: Rect) {
1420    let chunks = vertical(
1421        [
1422            Constraint::Length(1), // Description
1423            Constraint::Min(0),    // Table
1424        ],
1425        area,
1426    );
1427
1428    let desc = Paragraph::new(
1429        "A user group is a collection of IAM users. Use groups to specify permissions for a collection of users. A user can be a member of up to 10 groups at a time.",
1430    )
1431    .style(Style::default().fg(Color::White));
1432    frame.render_widget(desc, chunks[0]);
1433
1434    render_user_groups_table(frame, app, chunks[1]);
1435}
1436
1437pub fn render_user_groups_table(frame: &mut Frame, app: &App, area: Rect) {
1438    let chunks = vertical(
1439        [
1440            Constraint::Length(3), // Filter with pagination
1441            Constraint::Min(0),    // Table
1442        ],
1443        area,
1444    );
1445
1446    let cursor = get_cursor(app.mode == Mode::FilterInput);
1447    let page_size = app.iam_state.user_group_memberships.page_size.value();
1448    let filtered_groups: Vec<_> = app
1449        .iam_state
1450        .user_group_memberships
1451        .items
1452        .iter()
1453        .filter(|g| {
1454            if app.iam_state.user_group_memberships.filter.is_empty() {
1455                true
1456            } else {
1457                g.group_name
1458                    .to_lowercase()
1459                    .contains(&app.iam_state.user_group_memberships.filter.to_lowercase())
1460            }
1461        })
1462        .collect();
1463
1464    let filtered_count = filtered_groups.len();
1465    let total_pages = filtered_count.div_ceil(page_size);
1466    let current_page = app.iam_state.user_group_memberships.selected / page_size;
1467    let pagination = render_pagination_text(current_page, total_pages);
1468
1469    let filter_width = (chunks[0].width as usize).saturating_sub(4);
1470    let pagination_len = pagination.len();
1471    let available_space = filter_width.saturating_sub(pagination_len + 1);
1472
1473    let mut first_line_spans = vec![];
1474    if app.iam_state.user_group_memberships.filter.is_empty() && app.mode != Mode::FilterInput {
1475        first_line_spans.push(Span::styled("Search", Style::default().fg(Color::DarkGray)));
1476    } else {
1477        first_line_spans.push(Span::raw(&app.iam_state.user_group_memberships.filter));
1478    }
1479    if app.mode == Mode::FilterInput {
1480        first_line_spans.push(Span::raw(cursor));
1481    }
1482
1483    let content_len = if app.iam_state.user_group_memberships.filter.is_empty()
1484        && app.mode != Mode::FilterInput
1485    {
1486        6
1487    } else {
1488        app.iam_state.user_group_memberships.filter.len() + cursor.len()
1489    };
1490
1491    if content_len < available_space {
1492        first_line_spans.push(Span::raw(
1493            " ".repeat(available_space.saturating_sub(content_len)),
1494        ));
1495    }
1496    first_line_spans.push(Span::styled(
1497        pagination,
1498        Style::default().fg(Color::DarkGray),
1499    ));
1500
1501    frame.render_widget(
1502        Paragraph::new(Line::from(first_line_spans)).block(
1503            Block::default()
1504                .title(SEARCH_ICON)
1505                .borders(Borders::ALL)
1506                .border_style(if app.mode == Mode::FilterInput {
1507                    Style::default().fg(Color::Yellow)
1508                } else {
1509                    Style::default()
1510                }),
1511        ),
1512        chunks[0],
1513    );
1514
1515    let scroll_offset = app.iam_state.user_group_memberships.scroll_offset;
1516    let page_groups: Vec<&crate::iam::UserGroup> = filtered_groups
1517        .into_iter()
1518        .skip(scroll_offset)
1519        .take(page_size)
1520        .collect();
1521
1522    struct GroupNameColumn;
1523    impl crate::ui::table::Column<crate::iam::UserGroup> for GroupNameColumn {
1524        fn name(&self) -> &str {
1525            "Group name"
1526        }
1527        fn width(&self) -> u16 {
1528            40
1529        }
1530        fn render(&self, item: &crate::iam::UserGroup) -> (String, Style) {
1531            (item.group_name.clone(), Style::default())
1532        }
1533    }
1534
1535    struct AttachedPoliciesColumn;
1536    impl crate::ui::table::Column<crate::iam::UserGroup> for AttachedPoliciesColumn {
1537        fn name(&self) -> &str {
1538            "Attached policies"
1539        }
1540        fn width(&self) -> u16 {
1541            60
1542        }
1543        fn render(&self, item: &crate::iam::UserGroup) -> (String, Style) {
1544            (item.attached_policies.clone(), Style::default())
1545        }
1546    }
1547
1548    let columns: Vec<Box<dyn crate::ui::table::Column<crate::iam::UserGroup>>> =
1549        vec![Box::new(GroupNameColumn), Box::new(AttachedPoliciesColumn)];
1550
1551    let expanded_index = app
1552        .iam_state
1553        .user_group_memberships
1554        .expanded_item
1555        .and_then(|idx| {
1556            if idx >= scroll_offset && idx < scroll_offset + page_size {
1557                Some(idx - scroll_offset)
1558            } else {
1559                None
1560            }
1561        });
1562
1563    let config = crate::ui::table::TableConfig {
1564        items: page_groups,
1565        selected_index: app.iam_state.user_group_memberships.selected - scroll_offset,
1566        expanded_index,
1567        columns: &columns,
1568        sort_column: "",
1569        sort_direction: SortDirection::Asc,
1570        title: format!(
1571            " User groups membership ({}) ",
1572            app.iam_state.user_group_memberships.items.len()
1573        ),
1574        area: chunks[1],
1575        is_active: true,
1576        get_expanded_content: Some(Box::new(|group: &crate::iam::UserGroup| {
1577            crate::ui::table::plain_expanded_content(format!(
1578                "Group: {}\nAttached policies: {}",
1579                group.group_name, group.attached_policies
1580            ))
1581        })),
1582    };
1583
1584    crate::ui::table::render_table(frame, config);
1585}
1586
1587pub fn render_user_last_accessed_tab(frame: &mut Frame, app: &App, area: Rect) {
1588    let chunks = vertical(
1589        [
1590            Constraint::Length(1), // Description
1591            Constraint::Length(1), // Note above table
1592            Constraint::Min(0),    // Table with filters
1593        ],
1594        area,
1595    );
1596
1597    frame.render_widget(
1598        Paragraph::new(
1599            "Last accessed information shows the services that this user can access and when those services were last accessed. Review this data to remove unused permissions."
1600        ),
1601        chunks[0],
1602    );
1603
1604    frame.render_widget(
1605        Paragraph::new(
1606            "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."
1607        ),
1608        chunks[1],
1609    );
1610
1611    render_last_accessed_table(frame, app, chunks[2]);
1612}
1613
1614pub fn render_user_tags_table(frame: &mut Frame, app: &App, area: Rect) {
1615    let chunks = vertical(
1616        [
1617            Constraint::Length(3), // Filter with pagination
1618            Constraint::Min(0),    // Table
1619        ],
1620        area,
1621    );
1622
1623    // Filter
1624    let cursor = get_cursor(app.mode == Mode::FilterInput);
1625    let page_size = app.iam_state.user_tags.page_size.value();
1626    let filtered_tags: Vec<_> = app
1627        .iam_state
1628        .user_tags
1629        .items
1630        .iter()
1631        .filter(|t| {
1632            if app.iam_state.user_tags.filter.is_empty() {
1633                true
1634            } else {
1635                t.key
1636                    .to_lowercase()
1637                    .contains(&app.iam_state.user_tags.filter.to_lowercase())
1638                    || t.value
1639                        .to_lowercase()
1640                        .contains(&app.iam_state.user_tags.filter.to_lowercase())
1641            }
1642        })
1643        .collect();
1644
1645    let filtered_count = filtered_tags.len();
1646    let total_pages = filtered_count.div_ceil(page_size);
1647    let current_page = app.iam_state.user_tags.selected / page_size;
1648    let pagination = render_pagination_text(current_page, total_pages);
1649
1650    let filter_width = (chunks[0].width as usize).saturating_sub(4);
1651    let pagination_len = pagination.len();
1652    let available_space = filter_width.saturating_sub(pagination_len + 1);
1653
1654    let mut first_line_spans = vec![];
1655    if app.iam_state.user_tags.filter.is_empty() && app.mode != Mode::FilterInput {
1656        first_line_spans.push(Span::styled("Search", Style::default().fg(Color::DarkGray)));
1657    } else {
1658        first_line_spans.push(Span::raw(&app.iam_state.user_tags.filter));
1659    }
1660    if app.mode == Mode::FilterInput {
1661        first_line_spans.push(Span::raw(cursor));
1662    }
1663
1664    let content_len = if app.iam_state.user_tags.filter.is_empty() && app.mode != Mode::FilterInput
1665    {
1666        6
1667    } else {
1668        app.iam_state.user_tags.filter.len() + cursor.len()
1669    };
1670
1671    if content_len < available_space {
1672        first_line_spans.push(Span::raw(
1673            " ".repeat(available_space.saturating_sub(content_len)),
1674        ));
1675    }
1676    first_line_spans.push(Span::styled(
1677        pagination,
1678        Style::default().fg(Color::DarkGray),
1679    ));
1680
1681    frame.render_widget(
1682        Paragraph::new(Line::from(first_line_spans)).block(
1683            Block::default()
1684                .title(SEARCH_ICON)
1685                .borders(Borders::ALL)
1686                .border_style(if app.mode == Mode::FilterInput {
1687                    Style::default().fg(Color::Yellow)
1688                } else {
1689                    Style::default()
1690                }),
1691        ),
1692        chunks[0],
1693    );
1694
1695    // Table using common render_table
1696    let scroll_offset = app.iam_state.user_tags.scroll_offset;
1697    let page_tags: Vec<&crate::iam::UserTag> = filtered_tags
1698        .into_iter()
1699        .skip(scroll_offset)
1700        .take(page_size)
1701        .collect();
1702
1703    struct KeyColumn;
1704    impl crate::ui::table::Column<crate::iam::UserTag> for KeyColumn {
1705        fn name(&self) -> &str {
1706            "Key"
1707        }
1708        fn width(&self) -> u16 {
1709            30
1710        }
1711        fn render(&self, item: &crate::iam::UserTag) -> (String, Style) {
1712            (item.key.clone(), Style::default())
1713        }
1714    }
1715
1716    struct ValueColumn;
1717    impl crate::ui::table::Column<crate::iam::UserTag> for ValueColumn {
1718        fn name(&self) -> &str {
1719            "Value"
1720        }
1721        fn width(&self) -> u16 {
1722            70
1723        }
1724        fn render(&self, item: &crate::iam::UserTag) -> (String, Style) {
1725            (item.value.clone(), Style::default())
1726        }
1727    }
1728
1729    let columns: Vec<Box<dyn crate::ui::table::Column<crate::iam::UserTag>>> =
1730        vec![Box::new(KeyColumn), Box::new(ValueColumn)];
1731
1732    let expanded_index = app.iam_state.user_tags.expanded_item.and_then(|idx| {
1733        if idx >= scroll_offset && idx < scroll_offset + page_size {
1734            Some(idx - scroll_offset)
1735        } else {
1736            None
1737        }
1738    });
1739
1740    let config = crate::ui::table::TableConfig {
1741        items: page_tags,
1742        selected_index: app.iam_state.user_tags.selected - scroll_offset,
1743        expanded_index,
1744        columns: &columns,
1745        sort_column: "",
1746        sort_direction: SortDirection::Asc,
1747        title: format!(" Tags ({}) ", app.iam_state.user_tags.items.len()),
1748        area: chunks[1],
1749        is_active: true,
1750        get_expanded_content: Some(Box::new(|tag: &crate::iam::UserTag| {
1751            crate::ui::table::plain_expanded_content(format!(
1752                "Key: {}\nValue: {}",
1753                tag.key, tag.value
1754            ))
1755        })),
1756    };
1757
1758    crate::ui::table::render_table(frame, config);
1759}
1760
1761pub fn render_last_accessed_table(frame: &mut Frame, app: &App, area: Rect) {
1762    let chunks = vertical(
1763        [
1764            Constraint::Length(3), // Filter with dropdown and pagination
1765            Constraint::Min(0),    // Table
1766        ],
1767        area,
1768    );
1769
1770    // Filter
1771    let cursor = get_cursor(app.mode == Mode::FilterInput);
1772    let page_size = app.iam_state.last_accessed_services.page_size.value();
1773    let filtered_services: Vec<_> = app
1774        .iam_state
1775        .last_accessed_services
1776        .items
1777        .iter()
1778        .filter(|s| {
1779            let matches_filter = if app.iam_state.last_accessed_filter.is_empty() {
1780                true
1781            } else {
1782                s.service
1783                    .to_lowercase()
1784                    .contains(&app.iam_state.last_accessed_filter.to_lowercase())
1785            };
1786            let matches_history = match app.iam_state.last_accessed_history_filter {
1787                AccessHistoryFilter::NoFilter => true,
1788                AccessHistoryFilter::ServicesAccessed => {
1789                    !s.last_accessed.is_empty() && s.last_accessed != "Not accessed"
1790                }
1791                AccessHistoryFilter::ServicesNotAccessed => {
1792                    s.last_accessed.is_empty() || s.last_accessed == "Not accessed"
1793                }
1794            };
1795            matches_filter && matches_history
1796        })
1797        .collect();
1798
1799    let filtered_count = filtered_services.len();
1800    let total_pages = filtered_count.div_ceil(page_size);
1801    let current_page = app.iam_state.last_accessed_services.selected / page_size;
1802    let pagination = render_pagination_text(current_page, total_pages);
1803    let dropdown = format!(
1804        "Filter by services access history: {}",
1805        app.iam_state.last_accessed_history_filter.name()
1806    );
1807
1808    let filter_width = (chunks[0].width as usize).saturating_sub(4);
1809    let right_content = format!("{} ⋮ {}", dropdown, pagination);
1810    let right_len = right_content.len();
1811    let available_space = filter_width.saturating_sub(right_len + 1);
1812
1813    let mut first_line_spans = vec![];
1814    if app.iam_state.last_accessed_filter.is_empty() && app.mode != Mode::FilterInput {
1815        first_line_spans.push(Span::styled("Search", Style::default().fg(Color::DarkGray)));
1816    } else {
1817        let display_text = if app.iam_state.last_accessed_filter.len() > available_space {
1818            format!(
1819                "...{}",
1820                &app.iam_state.last_accessed_filter
1821                    [app.iam_state.last_accessed_filter.len() - available_space + 3..]
1822            )
1823        } else {
1824            app.iam_state.last_accessed_filter.clone()
1825        };
1826        first_line_spans.push(Span::raw(display_text));
1827    }
1828    if app.mode == Mode::FilterInput {
1829        first_line_spans.push(Span::raw(cursor));
1830    }
1831
1832    first_line_spans.push(Span::raw(
1833        " ".repeat(
1834            available_space.saturating_sub(
1835                first_line_spans
1836                    .iter()
1837                    .map(|s| s.content.len())
1838                    .sum::<usize>(),
1839            ),
1840        ),
1841    ));
1842    first_line_spans.push(Span::styled(
1843        right_content,
1844        Style::default().fg(Color::DarkGray),
1845    ));
1846
1847    frame.render_widget(
1848        Paragraph::new(Line::from(first_line_spans)).block(
1849            Block::default()
1850                .title(SEARCH_ICON)
1851                .borders(Borders::ALL)
1852                .border_style(if app.mode == Mode::FilterInput {
1853                    Style::default().fg(Color::Yellow)
1854                } else {
1855                    Style::default()
1856                }),
1857        ),
1858        chunks[0],
1859    );
1860
1861    // Table using common render_table
1862    let scroll_offset = app.iam_state.last_accessed_services.scroll_offset;
1863    let page_services: Vec<&crate::iam::LastAccessedService> = filtered_services
1864        .into_iter()
1865        .skip(scroll_offset)
1866        .take(page_size)
1867        .collect();
1868
1869    struct ServiceColumn;
1870    impl crate::ui::table::Column<crate::iam::LastAccessedService> for ServiceColumn {
1871        fn name(&self) -> &str {
1872            "Service"
1873        }
1874        fn width(&self) -> u16 {
1875            30
1876        }
1877        fn render(&self, item: &crate::iam::LastAccessedService) -> (String, Style) {
1878            (item.service.clone(), Style::default())
1879        }
1880    }
1881
1882    struct PoliciesColumn;
1883    impl crate::ui::table::Column<crate::iam::LastAccessedService> for PoliciesColumn {
1884        fn name(&self) -> &str {
1885            "Policies granting permissions"
1886        }
1887        fn width(&self) -> u16 {
1888            40
1889        }
1890        fn render(&self, item: &crate::iam::LastAccessedService) -> (String, Style) {
1891            (item.policies_granting.clone(), Style::default())
1892        }
1893    }
1894
1895    struct LastAccessedColumn;
1896    impl crate::ui::table::Column<crate::iam::LastAccessedService> for LastAccessedColumn {
1897        fn name(&self) -> &str {
1898            "Last accessed"
1899        }
1900        fn width(&self) -> u16 {
1901            30
1902        }
1903        fn render(&self, item: &crate::iam::LastAccessedService) -> (String, Style) {
1904            (item.last_accessed.clone(), Style::default())
1905        }
1906    }
1907
1908    let columns: Vec<Box<dyn crate::ui::table::Column<crate::iam::LastAccessedService>>> = vec![
1909        Box::new(ServiceColumn),
1910        Box::new(PoliciesColumn),
1911        Box::new(LastAccessedColumn),
1912    ];
1913
1914    let expanded_index = app
1915        .iam_state
1916        .last_accessed_services
1917        .expanded_item
1918        .and_then(|idx| {
1919            if idx >= scroll_offset && idx < scroll_offset + page_size {
1920                Some(idx - scroll_offset)
1921            } else {
1922                None
1923            }
1924        });
1925
1926    let config = crate::ui::table::TableConfig {
1927        items: page_services,
1928        selected_index: app
1929            .iam_state
1930            .last_accessed_services
1931            .selected
1932            .saturating_sub(scroll_offset),
1933        expanded_index,
1934        columns: &columns,
1935        sort_column: "Last accessed",
1936        sort_direction: SortDirection::Desc,
1937        title: format!(
1938            " Allowed services ({}) ",
1939            app.iam_state.last_accessed_services.items.len()
1940        ),
1941        area: chunks[1],
1942        is_active: true,
1943        get_expanded_content: Some(Box::new(|service: &crate::iam::LastAccessedService| {
1944            crate::ui::table::plain_expanded_content(format!(
1945                "Service: {}\nPolicies granting permissions: {}\nLast accessed: {}",
1946                service.service, service.policies_granting, service.last_accessed
1947            ))
1948        })),
1949    };
1950
1951    crate::ui::table::render_table(frame, config);
1952}
1953
1954// IAM-specific helper functions
1955pub fn filtered_iam_users(app: &App) -> Vec<&crate::iam::IamUser> {
1956    if app.iam_state.users.filter.is_empty() {
1957        app.iam_state.users.items.iter().collect()
1958    } else {
1959        app.iam_state
1960            .users
1961            .items
1962            .iter()
1963            .filter(|u| {
1964                u.user_name
1965                    .to_lowercase()
1966                    .contains(&app.iam_state.users.filter.to_lowercase())
1967            })
1968            .collect()
1969    }
1970}
1971
1972pub fn filtered_iam_roles(app: &App) -> Vec<&crate::iam::IamRole> {
1973    if app.iam_state.roles.filter.is_empty() {
1974        app.iam_state.roles.items.iter().collect()
1975    } else {
1976        app.iam_state
1977            .roles
1978            .items
1979            .iter()
1980            .filter(|r| {
1981                r.role_name
1982                    .to_lowercase()
1983                    .contains(&app.iam_state.roles.filter.to_lowercase())
1984            })
1985            .collect()
1986    }
1987}
1988
1989pub fn filtered_iam_policies(app: &App) -> Vec<&crate::iam::Policy> {
1990    app.iam_state
1991        .policies
1992        .items
1993        .iter()
1994        .filter(|p| {
1995            let matches_filter = if app.iam_state.policies.filter.is_empty() {
1996                true
1997            } else {
1998                p.policy_name
1999                    .to_lowercase()
2000                    .contains(&app.iam_state.policies.filter.to_lowercase())
2001            };
2002            let matches_type = if app.iam_state.policy_type_filter == "All types" {
2003                true
2004            } else {
2005                p.policy_type == app.iam_state.policy_type_filter
2006            };
2007            matches_filter && matches_type
2008        })
2009        .collect()
2010}
2011
2012pub fn filtered_tags(app: &App) -> Vec<&crate::iam::RoleTag> {
2013    if app.iam_state.tags.filter.is_empty() {
2014        app.iam_state.tags.items.iter().collect()
2015    } else {
2016        app.iam_state
2017            .tags
2018            .items
2019            .iter()
2020            .filter(|t| {
2021                t.key
2022                    .to_lowercase()
2023                    .contains(&app.iam_state.tags.filter.to_lowercase())
2024                    || t.value
2025                        .to_lowercase()
2026                        .contains(&app.iam_state.tags.filter.to_lowercase())
2027            })
2028            .collect()
2029    }
2030}
2031
2032pub fn filtered_user_tags(app: &App) -> Vec<&crate::iam::UserTag> {
2033    if app.iam_state.user_tags.filter.is_empty() {
2034        app.iam_state.user_tags.items.iter().collect()
2035    } else {
2036        app.iam_state
2037            .user_tags
2038            .items
2039            .iter()
2040            .filter(|t| {
2041                t.key
2042                    .to_lowercase()
2043                    .contains(&app.iam_state.user_tags.filter.to_lowercase())
2044                    || t.value
2045                        .to_lowercase()
2046                        .contains(&app.iam_state.user_tags.filter.to_lowercase())
2047            })
2048            .collect()
2049    }
2050}
2051
2052pub fn filtered_last_accessed(app: &App) -> Vec<&crate::iam::LastAccessedService> {
2053    app.iam_state
2054        .last_accessed_services
2055        .items
2056        .iter()
2057        .filter(|s| {
2058            let matches_filter = if app.iam_state.last_accessed_filter.is_empty() {
2059                true
2060            } else {
2061                s.service
2062                    .to_lowercase()
2063                    .contains(&app.iam_state.last_accessed_filter.to_lowercase())
2064            };
2065            let matches_history = match app.iam_state.last_accessed_history_filter {
2066                crate::ui::iam::AccessHistoryFilter::NoFilter => true,
2067                crate::ui::iam::AccessHistoryFilter::ServicesAccessed => {
2068                    !s.last_accessed.is_empty() && s.last_accessed != "Not accessed"
2069                }
2070                crate::ui::iam::AccessHistoryFilter::ServicesNotAccessed => {
2071                    s.last_accessed.is_empty() || s.last_accessed == "Not accessed"
2072                }
2073            };
2074            matches_filter && matches_history
2075        })
2076        .collect()
2077}
2078
2079pub async fn load_iam_users(app: &mut App) -> anyhow::Result<()> {
2080    let users = app
2081        .iam_client
2082        .list_users()
2083        .await
2084        .map_err(|e| anyhow::anyhow!(e))?;
2085
2086    let mut iam_users = Vec::new();
2087    for u in users {
2088        let user_name = u.user_name().to_string();
2089
2090        let has_console = app
2091            .iam_client
2092            .get_login_profile(&user_name)
2093            .await
2094            .unwrap_or(false);
2095        let access_key_count = app
2096            .iam_client
2097            .list_access_keys(&user_name)
2098            .await
2099            .unwrap_or(0);
2100        let creation_time = {
2101            let dt = u.create_date();
2102            let timestamp = dt.secs();
2103            let datetime = chrono::DateTime::from_timestamp(timestamp, 0).unwrap_or_default();
2104            datetime.format("%Y-%m-%d %H:%M:%S (UTC)").to_string()
2105        };
2106
2107        iam_users.push(crate::iam::IamUser {
2108            user_name,
2109            path: u.path().to_string(),
2110            groups: String::new(),
2111            last_activity: String::new(),
2112            mfa: String::new(),
2113            password_age: String::new(),
2114            console_last_sign_in: String::new(),
2115            access_key_id: access_key_count.to_string(),
2116            active_key_age: String::new(),
2117            access_key_last_used: String::new(),
2118            arn: u.arn().to_string(),
2119            creation_time,
2120            console_access: if has_console {
2121                "Enabled".to_string()
2122            } else {
2123                "Disabled".to_string()
2124            },
2125            signing_certs: String::new(),
2126        });
2127    }
2128
2129    app.iam_state.users.items = iam_users;
2130
2131    Ok(())
2132}
2133
2134pub async fn load_iam_roles(app: &mut App) -> anyhow::Result<()> {
2135    let roles = app
2136        .iam_client
2137        .list_roles()
2138        .await
2139        .map_err(|e| anyhow::anyhow!(e))?;
2140
2141    let roles: Vec<crate::iam::IamRole> = roles
2142        .into_iter()
2143        .map(|r| {
2144            let trusted_entities = r
2145                .assume_role_policy_document()
2146                .and_then(|doc| {
2147                    let decoded = urlencoding::decode(doc).ok()?;
2148                    let policy: serde_json::Value = serde_json::from_str(&decoded).ok()?;
2149                    let statements = policy.get("Statement")?.as_array()?;
2150
2151                    let mut entities = Vec::new();
2152                    for stmt in statements {
2153                        if let Some(principal) = stmt.get("Principal") {
2154                            if let Some(service) = principal.get("Service") {
2155                                if let Some(s) = service.as_str() {
2156                                    let clean = s.replace(".amazonaws.com", "");
2157                                    entities.push(format!("AWS Service: {}", clean));
2158                                } else if let Some(arr) = service.as_array() {
2159                                    for s in arr {
2160                                        if let Some(s) = s.as_str() {
2161                                            let clean = s.replace(".amazonaws.com", "");
2162                                            entities.push(format!("AWS Service: {}", clean));
2163                                        }
2164                                    }
2165                                }
2166                            }
2167                            if let Some(aws) = principal.get("AWS") {
2168                                if let Some(a) = aws.as_str() {
2169                                    if a.starts_with("arn:aws:iam::") {
2170                                        if let Some(account) = a.split(':').nth(4) {
2171                                            entities.push(format!("Account: {}", account));
2172                                        }
2173                                    } else {
2174                                        entities.push(format!("Account: {}", a));
2175                                    }
2176                                } else if let Some(arr) = aws.as_array() {
2177                                    for a in arr {
2178                                        if let Some(a) = a.as_str() {
2179                                            if a.starts_with("arn:aws:iam::") {
2180                                                if let Some(account) = a.split(':').nth(4) {
2181                                                    entities.push(format!("Account: {}", account));
2182                                                }
2183                                            } else {
2184                                                entities.push(format!("Account: {}", a));
2185                                            }
2186                                        }
2187                                    }
2188                                }
2189                            }
2190                        }
2191                    }
2192                    Some(entities.join(", "))
2193                })
2194                .unwrap_or_default();
2195
2196            let last_activity = r
2197                .role_last_used()
2198                .and_then(|last_used| {
2199                    last_used.last_used_date().map(|dt| {
2200                        let timestamp = dt.secs();
2201                        let datetime =
2202                            chrono::DateTime::from_timestamp(timestamp, 0).unwrap_or_default();
2203                        datetime.format("%Y-%m-%d %H:%M:%S (UTC)").to_string()
2204                    })
2205                })
2206                .or_else(|| {
2207                    r.role_last_used().and_then(|last_used| {
2208                        last_used
2209                            .region()
2210                            .map(|region| format!("Used in {}", region))
2211                    })
2212                })
2213                .unwrap_or_else(|| "-".to_string());
2214
2215            crate::iam::IamRole {
2216                role_name: r.role_name().to_string(),
2217                path: r.path().to_string(),
2218                trusted_entities,
2219                last_activity,
2220                arn: r.arn().to_string(),
2221                creation_time: {
2222                    let dt = r.create_date();
2223                    let timestamp = dt.secs();
2224                    let datetime =
2225                        chrono::DateTime::from_timestamp(timestamp, 0).unwrap_or_default();
2226                    datetime.format("%Y-%m-%d %H:%M:%S (UTC)").to_string()
2227                },
2228                description: r.description().unwrap_or("").to_string(),
2229                max_session_duration: r
2230                    .max_session_duration()
2231                    .map(|d| format!("{} seconds", d))
2232                    .unwrap_or_default(),
2233            }
2234        })
2235        .collect();
2236
2237    app.iam_state.roles.items = roles;
2238
2239    Ok(())
2240}
2241
2242pub async fn load_iam_user_groups(app: &mut App) -> anyhow::Result<()> {
2243    let groups = app
2244        .iam_client
2245        .list_groups()
2246        .await
2247        .map_err(|e| anyhow::anyhow!(e))?;
2248
2249    let mut iam_groups = Vec::new();
2250    for g in groups {
2251        let creation_time = {
2252            let dt = g.create_date();
2253            let timestamp = dt.secs();
2254            let datetime = chrono::DateTime::from_timestamp(timestamp, 0).unwrap_or_default();
2255            datetime.format("%Y-%m-%d %H:%M:%S (UTC)").to_string()
2256        };
2257
2258        let group_name = g.group_name().to_string();
2259        let user_count = app.iam_client.get_group(&group_name).await.unwrap_or(0);
2260
2261        iam_groups.push(crate::iam::IamGroup {
2262            group_name,
2263            path: g.path().to_string(),
2264            users: user_count.to_string(),
2265            permissions: "Defined".to_string(),
2266            creation_time,
2267        });
2268    }
2269
2270    app.iam_state.groups.items = iam_groups;
2271
2272    Ok(())
2273}
2274
2275#[cfg(test)]
2276mod tests {
2277    use super::*;
2278
2279    #[test]
2280    fn test_policy_input_focus_next() {
2281        assert_eq!(
2282            InputFocus::Filter.next(&POLICY_FILTER_CONTROLS),
2283            POLICY_TYPE_DROPDOWN
2284        );
2285        assert_eq!(
2286            POLICY_TYPE_DROPDOWN.next(&POLICY_FILTER_CONTROLS),
2287            InputFocus::Pagination
2288        );
2289        assert_eq!(
2290            InputFocus::Pagination.next(&POLICY_FILTER_CONTROLS),
2291            InputFocus::Filter
2292        );
2293    }
2294
2295    #[test]
2296    fn test_policy_input_focus_prev() {
2297        assert_eq!(
2298            InputFocus::Filter.prev(&POLICY_FILTER_CONTROLS),
2299            InputFocus::Pagination
2300        );
2301        assert_eq!(
2302            InputFocus::Pagination.prev(&POLICY_FILTER_CONTROLS),
2303            POLICY_TYPE_DROPDOWN
2304        );
2305        assert_eq!(
2306            POLICY_TYPE_DROPDOWN.prev(&POLICY_FILTER_CONTROLS),
2307            InputFocus::Filter
2308        );
2309    }
2310
2311    #[test]
2312    fn test_role_input_focus_next() {
2313        assert_eq!(
2314            InputFocus::Filter.next(&ROLE_FILTER_CONTROLS),
2315            InputFocus::Pagination
2316        );
2317        assert_eq!(
2318            InputFocus::Pagination.next(&ROLE_FILTER_CONTROLS),
2319            InputFocus::Filter
2320        );
2321    }
2322
2323    #[test]
2324    fn test_role_input_focus_prev() {
2325        assert_eq!(
2326            InputFocus::Filter.prev(&ROLE_FILTER_CONTROLS),
2327            InputFocus::Pagination
2328        );
2329        assert_eq!(
2330            InputFocus::Pagination.prev(&ROLE_FILTER_CONTROLS),
2331            InputFocus::Filter
2332        );
2333    }
2334}