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