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