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