1use chrono::{Local, TimeZone};
31use ratatui::{
32 layout::{Constraint, Direction, Layout, Rect},
33 style::{Color, Modifier, Style},
34 text::{Line, Span},
35 widgets::{Block, Borders, List, ListItem, Paragraph, Tabs, Wrap},
36 Frame,
37};
38
39use crate::app::{AdminField, App, AppScreen, Tab};
40use crate::formatting;
41
42pub fn draw(f: &mut Frame, app: &App) {
49 if app.screen == AppScreen::Splash {
50 render_splash(f, app, f.area());
51 return;
52 }
53
54 let chunks = Layout::default()
55 .direction(Direction::Vertical)
56 .constraints([
57 Constraint::Length(3), Constraint::Min(0), Constraint::Length(1), ])
61 .split(f.area());
62
63 render_tabs(f, app, chunks[0]);
64
65 match app.active_tab {
66 Tab::Chat => render_chat_tab(f, app, chunks[1]),
67 Tab::Admin => render_admin_tab(f, app, chunks[1]),
68 Tab::Status => render_status_tab(f, app, chunks[1]),
69 Tab::Help => render_help_tab(f, app, chunks[1]),
70 }
71
72 render_status_bar(f, app, chunks[2]);
74}
75
76fn render_tabs(f: &mut Frame, app: &App, area: Rect) {
77 let titles: Vec<Line<'static>> = ["Chat", "Admin", "Status", "Help"]
79 .iter()
80 .map(|t| Line::from(*t))
81 .collect();
82
83 let tabs = Tabs::new(titles)
84 .block(Block::default().borders(Borders::ALL).title("Cryptic TUI"))
85 .select(app.active_tab as usize)
86 .style(Style::default().fg(Color::White))
87 .highlight_style(
88 Style::default()
89 .fg(Color::Cyan)
90 .add_modifier(Modifier::BOLD),
91 );
92
93 f.render_widget(tabs, area);
94}
95
96fn render_chat_tab(f: &mut Frame, app: &App, area: Rect) {
97 let chunks = Layout::default()
98 .direction(Direction::Horizontal)
99 .constraints([
100 Constraint::Percentage(30), Constraint::Percentage(70), ])
103 .split(area);
104
105 render_user_list(f, app, chunks[0]);
107
108 render_chat_area(f, app, chunks[1]);
110}
111
112fn render_user_list(f: &mut Frame, app: &App, area: Rect) {
113 let users: Vec<ListItem> = app
114 .users
115 .iter()
116 .enumerate()
117 .map(|(i, user)| {
118 let status = if user.online { "●" } else { "○" };
119 let style = if i == app.selected_user {
120 Style::default()
121 .fg(Color::Cyan)
122 .add_modifier(Modifier::BOLD)
123 } else {
124 Style::default()
125 };
126
127 let content = format!(
128 "{} {} {}",
129 status,
130 user.name,
131 if user.online { "online" } else { "offline" }
132 );
133
134 ListItem::new(content).style(style)
135 })
136 .collect();
137
138 let user_list = List::new(users)
139 .block(
140 Block::default()
141 .borders(Borders::ALL)
142 .title("Users (↑/↓ to select, Enter to chat)"),
143 )
144 .style(Style::default().fg(Color::White));
145
146 f.render_widget(user_list, area);
147}
148
149fn render_chat_area(f: &mut Frame, app: &App, area: Rect) {
150 let chunks = Layout::default()
151 .direction(Direction::Vertical)
152 .constraints([
153 Constraint::Length(3), Constraint::Min(0), Constraint::Length(3), ])
157 .split(area);
158
159 let peer_name = app.current_peer.as_deref().unwrap_or("No chat selected");
161 let header = Paragraph::new(format!("Chat with: {}", peer_name))
162 .block(Block::default().borders(Borders::ALL))
163 .style(
164 Style::default()
165 .fg(Color::Green)
166 .add_modifier(Modifier::BOLD),
167 );
168 f.render_widget(header, chunks[0]);
169
170 render_messages(f, app, chunks[1]);
172
173 let input_width = chunks[2].width.saturating_sub(2) as usize; let cursor_pos = app.input_cursor;
176
177 let scroll_offset = if cursor_pos >= input_width {
179 cursor_pos - input_width + 1
180 } else {
181 0
182 };
183
184 let input = Paragraph::new(app.input_buffer.as_str())
185 .block(
186 Block::default()
187 .borders(Borders::ALL)
188 .title("Message (Enter to send, Tab for next tab)"),
189 )
190 .style(Style::default().fg(Color::White))
191 .scroll((0, scroll_offset as u16)); f.render_widget(input, chunks[2]);
194
195 if app.current_peer.is_some() {
197 let cursor_x = chunks[2].x + 1 + (cursor_pos.saturating_sub(scroll_offset)) as u16;
198 let cursor_y = chunks[2].y + 1;
199 f.set_cursor_position((cursor_x, cursor_y));
200 }
201}
202
203fn render_messages(f: &mut Frame, app: &App, area: Rect) {
204 let mut lines: Vec<Line> = Vec::new();
205 let mut current_scroll_offset: usize = 0;
206 let mut has_messages = false;
207
208 if let Some(peer) = &app.current_peer {
209 if let Some(conv) = app.get_conversation(peer) {
210 has_messages = !conv.messages.is_empty();
211 current_scroll_offset = conv.scroll_offset;
212 if conv.is_loading {
214 lines.push(Line::from(Span::styled(
215 "⏳ Loading older messages...",
216 Style::default()
217 .fg(Color::Gray)
218 .add_modifier(Modifier::ITALIC),
219 )));
220 }
221
222 if !conv.has_more_history && !conv.messages.is_empty() {
224 lines.push(Line::from(Span::styled(
225 "─── Beginning of conversation ───",
226 Style::default().fg(Color::DarkGray),
227 )));
228 }
229
230 let mut last_date: Option<String> = None;
232
233 for msg in conv.messages.iter() {
234 let msg_date = format_message_date(msg.timestamp);
235
236 if last_date.as_ref() != Some(&msg_date) {
238 lines.push(Line::from(Span::styled(
239 format!("─── {} ───", msg_date),
240 Style::default()
241 .fg(Color::Blue)
242 .add_modifier(Modifier::BOLD),
243 )));
244 last_date = Some(msg_date);
245 }
246
247 let time_str = format_message_time(msg.timestamp);
249 let base_color = if msg.is_sent {
250 Color::Cyan
251 } else {
252 Color::Yellow
253 };
254
255 let prefix = if msg.is_sent { "me" } else { &msg.from };
256
257 let formatted_content = formatting::process_text(&msg.content);
259
260 let mut line_spans = vec![
262 Span::styled(format!("[{}] ", time_str), Style::default().fg(base_color)),
263 Span::styled(format!("{}: ", prefix), Style::default().fg(base_color)),
264 ];
265
266 for span in formatted_content.spans {
268 let mut style = span.style;
269 if style.fg.is_none() {
271 style.fg = Some(base_color);
272 }
273 line_spans.push(Span::styled(span.content, style));
274 }
275
276 lines.push(Line::from(line_spans));
277 }
278
279 if has_messages {
281 let scroll_info = if current_scroll_offset > 0 {
282 format!("↑ Scrolled up {} lines | Ctrl+U/D or PgUp/PgDn to scroll | Home/End to jump",
283 current_scroll_offset)
284 } else {
285 "↓ At newest messages | Ctrl+U or PgUp to scroll up".to_string()
286 };
287
288 lines.push(Line::from(Span::styled(
289 scroll_info,
290 Style::default()
291 .fg(Color::DarkGray)
292 .add_modifier(Modifier::ITALIC),
293 )));
294 }
295
296 if lines.is_empty() {
297 lines.push(Line::from("No messages yet. Start typing!"));
298 }
299 } else {
300 lines.push(Line::from("No messages yet. Start typing!"));
301 }
302 } else {
303 lines.push(Line::from("Select a user from the list to start chatting"));
304 }
305
306 let title = if let Some(peer) = &app.current_peer {
307 format!("Messages - {}", peer)
308 } else {
309 "Messages".to_string()
310 };
311
312 let inner_width = area.width.saturating_sub(2) as usize; let inner_width = inner_width.max(1);
315 let visible_height = area.height.saturating_sub(2) as usize; let visible_height = visible_height.max(1);
317
318 let total_height: usize = lines
319 .iter()
320 .map(|line| line_display_height(line, inner_width))
321 .sum();
322
323 let bottom_start = total_height.saturating_sub(visible_height);
324 let effective_offset = current_scroll_offset.min(bottom_start);
325 let scroll_y_lines = bottom_start.saturating_sub(effective_offset);
326 let scroll_y = scroll_y_lines.min(u16::MAX as usize) as u16;
327
328 if has_messages {
329 if let Some(last_line) = lines.last_mut() {
330 let scroll_info = if effective_offset > 0 {
331 format!(
332 "↑ Scrolled up {} lines | Ctrl+U/D or PgUp/PgDn to scroll | Home/End to jump",
333 effective_offset
334 )
335 } else {
336 "↓ At newest messages | Ctrl+U or PgUp to scroll up".to_string()
337 };
338
339 *last_line = Line::from(Span::styled(
340 scroll_info,
341 Style::default()
342 .fg(Color::DarkGray)
343 .add_modifier(Modifier::ITALIC),
344 ));
345 }
346 }
347
348 let paragraph = Paragraph::new(lines)
349 .block(Block::default().borders(Borders::ALL).title(title))
350 .style(Style::default().fg(Color::White))
351 .scroll((scroll_y, 0)) .wrap(Wrap { trim: false }); f.render_widget(paragraph, area);
355}
356
357fn line_display_height(line: &Line, available_width: usize) -> usize {
359 if available_width == 0 {
360 return 1;
361 }
362
363 let width = line.width();
364 if width == 0 {
365 1
366 } else {
367 (width + available_width - 1) / available_width
369 }
370}
371
372fn format_message_date(timestamp: u64) -> String {
375 let timestamp_i64 = timestamp.min(i64::MAX as u64) as i64;
377
378 let msg_time = match Local.timestamp_opt(timestamp_i64, 0) {
379 chrono::LocalResult::Single(dt) => dt,
380 _ => return "Unknown date".to_string(),
381 };
382
383 let now = Local::now();
384 let msg_date = msg_time.date_naive();
385 let today = now.date_naive();
386
387 if msg_date == today {
388 "Today".to_string()
389 } else if msg_date == today - chrono::Duration::days(1) {
390 "Yesterday".to_string()
391 } else {
392 msg_time.format("%a, %b %d, %Y").to_string()
393 }
394}
395
396fn format_message_time(timestamp: u64) -> String {
399 let timestamp_i64 = timestamp.min(i64::MAX as u64) as i64;
401
402 match Local.timestamp_opt(timestamp_i64, 0) {
403 chrono::LocalResult::Single(dt) => dt.format("%I:%M %p").to_string(),
404 _ => "??:??".to_string(),
405 }
406}
407
408fn render_admin_tab(f: &mut Frame, app: &App, area: Rect) {
409 use crate::app::AdminMode;
410
411 match app.admin_mode {
412 AdminMode::Menu => render_admin_menu(f, app, area),
413 AdminMode::RegisterUser => render_admin_register(f, app, area),
414 AdminMode::SuspendUser => render_admin_suspend(f, app, area),
415 AdminMode::ReactivateUser => render_admin_reactivate(f, app, area),
416 AdminMode::RevokeUser => render_admin_revoke(f, app, area),
417 AdminMode::ListCertificates => render_admin_certificates(f, app, area),
418 }
419}fn render_admin_menu(f: &mut Frame, app: &App, area: Rect) {
420 let mut content = vec![
421 Line::from(Span::styled(
422 "Admin Panel - User Management",
423 Style::default()
424 .fg(Color::Cyan)
425 .add_modifier(Modifier::BOLD),
426 )),
427 Line::from(""),
428 Line::from("Available actions:"),
429 Line::from(""),
430 Line::from(vec![
431 Span::styled(" l", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
432 Span::raw(" - List registered users"),
433 ]),
434 Line::from(vec![
435 Span::styled(" r", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
436 Span::raw(" - Register new user"),
437 ]),
438 Line::from(vec![
439 Span::styled(" s", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
440 Span::raw(" - Suspend user"),
441 ]),
442 Line::from(vec![
443 Span::styled(" a", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
444 Span::raw(" - Reactivate (activate) user"),
445 ]),
446 Line::from(vec![
447 Span::styled(" v", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
448 Span::raw(" - Revoke user"),
449 ]),
450 Line::from(vec![
451 Span::styled(" c", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
452 Span::raw(" - Certificate management"),
453 ]),
454 Line::from(""),
455 ];
456
457 if let Some(ref users) = app.detailed_users {
459 content.push(Line::from(""));
460 content.push(Line::from(Span::styled(
461 format!("Registered Users ({})", users.len()),
462 Style::default()
463 .fg(Color::Green)
464 .add_modifier(Modifier::BOLD),
465 )));
466 content.push(Line::from(""));
467
468 content.push(Line::from(vec![Span::styled(
470 format!(
471 "{:<12} {:<10} {:<8} {:<12} {:<12} {:<12}",
472 "Username", "Status", "Online", "Registered", "Last Seen", "GPG FP (short)"
473 ),
474 Style::default()
475 .fg(Color::Green)
476 .add_modifier(Modifier::BOLD),
477 )]));
478 content.push(Line::from("─".repeat(80)));
479
480 for (idx, user) in users.iter().enumerate() {
482 let online_indicator = if user.online { "🟢 Yes" } else { "⚫ No" };
483 let registered = format_timestamp(user.registered_at);
484 let last_seen = format_timestamp(user.last_seen);
485 let gpg_short = if user.gpg_fp.len() >= 8 {
486 &user.gpg_fp[user.gpg_fp.len() - 8..]
487 } else {
488 &user.gpg_fp
489 };
490
491 let status_style = match user.status.as_str() {
492 "active" => Style::default().fg(Color::Green),
493 "suspended" => Style::default().fg(Color::Yellow),
494 "revoked" => Style::default().fg(Color::Red),
495 _ => Style::default().fg(Color::Gray),
496 };
497
498 let online_style = if user.online {
499 Style::default().fg(Color::Green)
500 } else {
501 Style::default().fg(Color::Gray)
502 };
503
504 let is_selected = idx == app.admin_selected_user;
506 let selection_marker = if is_selected { "→ " } else { " " };
507
508 let mut line_style = Style::default();
509 if is_selected {
510 line_style = line_style.add_modifier(Modifier::BOLD).fg(Color::Cyan);
511 }
512
513 content.push(Line::from(vec![
514 Span::styled(selection_marker, line_style),
515 Span::styled(format!("{:<10} ", truncate_string(&user.username, 10)), line_style),
516 Span::styled(format!("{:<10} ", user.status), if is_selected { line_style } else { status_style }),
517 Span::styled(format!("{:<8} ", online_indicator), if is_selected { line_style } else { online_style }),
518 Span::styled(format!("{:<12} ", registered), line_style),
519 Span::styled(format!("{:<12} ", last_seen), line_style),
520 Span::styled(gpg_short.to_string(), if is_selected { line_style } else { Style::default().fg(Color::DarkGray) }),
521 ]));
522
523 if let Some(ref metadata) = user.metadata {
525 if !metadata.fields.is_empty() {
526 let mut keys: Vec<_> = metadata.fields.keys().collect();
528 keys.sort();
529
530 let metadata_str = keys
531 .iter()
532 .map(|k| format!("{}: {}", k, metadata.fields.get(*k).unwrap_or(&"".to_string())))
533 .collect::<Vec<_>>()
534 .join(", ");
535
536 content.push(Line::from(Span::styled(
537 format!(" └─ {}", metadata_str),
538 Style::default()
539 .fg(Color::DarkGray)
540 .add_modifier(Modifier::ITALIC),
541 )));
542 }
543 }
544 }
545
546 content.push(Line::from(""));
547 content.push(Line::from(Span::styled(
548 "Use ↑/↓ to select user",
549 Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC),
550 )));
551 } else {
552 content.push(Line::from(Span::styled(
553 "Press 'l' to load user list from server",
554 Style::default().fg(Color::Yellow),
555 )));
556 }
557
558 if let Some(ref status) = app.status_message {
559 content.push(Line::from(""));
560 content.push(Line::from(Span::styled(
561 status.clone(),
562 Style::default()
563 .fg(Color::LightCyan)
564 .add_modifier(Modifier::ITALIC),
565 )));
566 }
567
568 let paragraph = Paragraph::new(content)
569 .block(Block::default().borders(Borders::ALL).title("Admin"))
570 .style(Style::default().fg(Color::White))
571 .wrap(Wrap { trim: false })
572 .scroll((app.admin_scroll_offset, 0));
573
574 f.render_widget(paragraph, area);
575}
576
577fn render_admin_register(f: &mut Frame, app: &App, area: Rect) {
578 let chunks = Layout::default()
579 .direction(Direction::Vertical)
580 .constraints([
581 Constraint::Length(8), Constraint::Min(0), ])
584 .split(area);
585
586 render_admin_form(f, app, chunks[0]);
587}
588
589fn render_admin_suspend(f: &mut Frame, app: &App, area: Rect) {
590 let mut content = vec![
591 Line::from(Span::styled(
592 "Suspend User",
593 Style::default()
594 .fg(Color::Yellow)
595 .add_modifier(Modifier::BOLD),
596 )),
597 Line::from(""),
598 ];
599
600 if let Some(user) = app.admin_get_selected_user() {
601 let gpg_short = if user.gpg_fp.len() >= 16 {
602 &user.gpg_fp[user.gpg_fp.len() - 16..]
603 } else {
604 &user.gpg_fp
605 };
606
607 content.push(Line::from(vec![
608 Span::raw("Username: "),
609 Span::styled(&user.username, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
610 ]));
611 content.push(Line::from(vec![
612 Span::raw("Fingerprint: "),
613 Span::styled(gpg_short, Style::default().fg(Color::Gray)),
614 ]));
615 content.push(Line::from(vec![
616 Span::raw("Current Status: "),
617 Span::styled(&user.status, match user.status.as_str() {
618 "active" => Style::default().fg(Color::Green),
619 "suspended" => Style::default().fg(Color::Yellow),
620 "revoked" => Style::default().fg(Color::Red),
621 _ => Style::default().fg(Color::Gray),
622 }),
623 ]));
624 content.push(Line::from(""));
625
626 if user.status == "suspended" {
627 content.push(Line::from(Span::styled(
628 "⚠ User is already suspended",
629 Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC),
630 )));
631 content.push(Line::from(""));
632 content.push(Line::from(Span::styled(
633 "Press Esc to return to menu",
634 Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC),
635 )));
636 } else if user.status == "revoked" {
637 content.push(Line::from(Span::styled(
638 "✗ Cannot suspend revoked user",
639 Style::default().fg(Color::Red).add_modifier(Modifier::ITALIC),
640 )));
641 content.push(Line::from(""));
642 content.push(Line::from(Span::styled(
643 "Press Esc to return to menu",
644 Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC),
645 )));
646 } else {
647 content.push(Line::from(Span::styled(
648 "Press Enter to confirm suspension, or Esc to cancel",
649 Style::default().fg(Color::White),
650 )));
651 }
652 } else {
653 content.push(Line::from(Span::styled(
654 "No user selected",
655 Style::default().fg(Color::Red),
656 )));
657 content.push(Line::from(""));
658 content.push(Line::from(Span::styled(
659 "Press Esc to return to menu",
660 Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC),
661 )));
662 }
663
664 if let Some(ref status) = app.status_message {
665 content.push(Line::from(""));
666 content.push(Line::from(Span::styled(
667 status.clone(),
668 Style::default()
669 .fg(Color::LightCyan)
670 .add_modifier(Modifier::ITALIC),
671 )));
672 }
673
674 let paragraph = Paragraph::new(content)
675 .block(Block::default().borders(Borders::ALL).title("Suspend User"))
676 .style(Style::default().fg(Color::White))
677 .wrap(Wrap { trim: false })
678 .scroll((app.admin_scroll_offset, 0));
679
680 f.render_widget(paragraph, area);
681}
682
683fn render_admin_reactivate(f: &mut Frame, app: &App, area: Rect) {
684 let mut content = vec![
685 Line::from(Span::styled(
686 "Reactivate User",
687 Style::default()
688 .fg(Color::Green)
689 .add_modifier(Modifier::BOLD),
690 )),
691 Line::from(""),
692 ];
693
694 if let Some(user) = app.admin_get_selected_user() {
695 let gpg_short = if user.gpg_fp.len() >= 16 {
696 &user.gpg_fp[user.gpg_fp.len() - 16..]
697 } else {
698 &user.gpg_fp
699 };
700
701 content.push(Line::from(vec![
702 Span::raw("Username: "),
703 Span::styled(&user.username, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
704 ]));
705 content.push(Line::from(vec![
706 Span::raw("Fingerprint: "),
707 Span::styled(gpg_short, Style::default().fg(Color::Gray)),
708 ]));
709 content.push(Line::from(vec![
710 Span::raw("Current Status: "),
711 Span::styled(&user.status, match user.status.as_str() {
712 "active" => Style::default().fg(Color::Green),
713 "suspended" => Style::default().fg(Color::Yellow),
714 "revoked" => Style::default().fg(Color::Red),
715 _ => Style::default().fg(Color::Gray),
716 }),
717 ]));
718 content.push(Line::from(""));
719
720 if user.status == "active" {
721 content.push(Line::from(Span::styled(
722 "✓ User is already active",
723 Style::default().fg(Color::Green).add_modifier(Modifier::ITALIC),
724 )));
725 content.push(Line::from(""));
726 content.push(Line::from(Span::styled(
727 "Press Esc to return to menu",
728 Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC),
729 )));
730 } else if user.status == "revoked" {
731 content.push(Line::from(Span::styled(
732 "✗ Cannot reactivate revoked user",
733 Style::default().fg(Color::Red).add_modifier(Modifier::ITALIC),
734 )));
735 content.push(Line::from(""));
736 content.push(Line::from(Span::styled(
737 "Press Esc to return to menu",
738 Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC),
739 )));
740 } else {
741 content.push(Line::from(Span::styled(
742 "Press Enter to confirm reactivation, or Esc to cancel",
743 Style::default().fg(Color::White),
744 )));
745 }
746 } else {
747 content.push(Line::from(Span::styled(
748 "No user selected",
749 Style::default().fg(Color::Red),
750 )));
751 content.push(Line::from(""));
752 content.push(Line::from(Span::styled(
753 "Press Esc to return to menu",
754 Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC),
755 )));
756 }
757
758 if let Some(ref status) = app.status_message {
759 content.push(Line::from(""));
760 content.push(Line::from(Span::styled(
761 status.clone(),
762 Style::default()
763 .fg(Color::LightCyan)
764 .add_modifier(Modifier::ITALIC),
765 )));
766 }
767
768 let paragraph = Paragraph::new(content)
769 .block(Block::default().borders(Borders::ALL).title("Reactivate User"))
770 .style(Style::default().fg(Color::White))
771 .wrap(Wrap { trim: false })
772 .scroll((app.admin_scroll_offset, 0));
773
774 f.render_widget(paragraph, area);
775}
776
777fn render_admin_revoke(f: &mut Frame, app: &App, area: Rect) {
778 let mut content = vec![
779 Line::from(Span::styled(
780 "Revoke User",
781 Style::default()
782 .fg(Color::Red)
783 .add_modifier(Modifier::BOLD),
784 )),
785 Line::from(""),
786 ];
787
788 if let Some(user) = app.admin_get_selected_user() {
789 let gpg_short = if user.gpg_fp.len() >= 16 {
790 &user.gpg_fp[user.gpg_fp.len() - 16..]
791 } else {
792 &user.gpg_fp
793 };
794
795 content.push(Line::from(vec![
796 Span::raw("Username: "),
797 Span::styled(&user.username, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
798 ]));
799 content.push(Line::from(vec![
800 Span::raw("Fingerprint: "),
801 Span::styled(gpg_short, Style::default().fg(Color::Gray)),
802 ]));
803 content.push(Line::from(vec![
804 Span::raw("Current Status: "),
805 Span::styled(&user.status, match user.status.as_str() {
806 "active" => Style::default().fg(Color::Green),
807 "suspended" => Style::default().fg(Color::Yellow),
808 "revoked" => Style::default().fg(Color::Red),
809 _ => Style::default().fg(Color::Gray),
810 }),
811 ]));
812 content.push(Line::from(""));
813
814 if user.status == "revoked" {
815 content.push(Line::from(Span::styled(
816 "✗ User is already revoked",
817 Style::default().fg(Color::Red).add_modifier(Modifier::ITALIC),
818 )));
819 content.push(Line::from(""));
820 content.push(Line::from(Span::styled(
821 "Press Esc to return to menu",
822 Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC),
823 )));
824 } else {
825 content.push(Line::from(Span::styled(
826 "⚠️ WARNING: Revoking a user is PERMANENT and cannot be undone!",
827 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
828 )));
829 content.push(Line::from(""));
830 content.push(Line::from(Span::styled(
831 "Press Enter to confirm revocation, or Esc to cancel",
832 Style::default().fg(Color::White),
833 )));
834 }
835 } else {
836 content.push(Line::from(Span::styled(
837 "No user selected",
838 Style::default().fg(Color::Red),
839 )));
840 content.push(Line::from(""));
841 content.push(Line::from(Span::styled(
842 "Press Esc to return to menu",
843 Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC),
844 )));
845 }
846
847 if let Some(ref status) = app.status_message {
848 content.push(Line::from(""));
849 content.push(Line::from(Span::styled(
850 status.clone(),
851 Style::default()
852 .fg(Color::LightCyan)
853 .add_modifier(Modifier::ITALIC),
854 )));
855 }
856
857 let paragraph = Paragraph::new(content)
858 .block(Block::default().borders(Borders::ALL).title("Revoke User"))
859 .style(Style::default().fg(Color::White))
860 .wrap(Wrap { trim: false })
861 .scroll((app.admin_scroll_offset, 0));
862
863 f.render_widget(paragraph, area);
864}
865
866fn render_admin_certificates(f: &mut Frame, app: &App, area: Rect) {
867 let mut content = vec![
868 Line::from(Span::styled(
869 "Certificate Management",
870 Style::default()
871 .fg(Color::Cyan)
872 .add_modifier(Modifier::BOLD),
873 )),
874 Line::from(""),
875 ];
876
877 if let Some(ref username) = app.certificates_for_user {
878 content.push(Line::from(vec![
879 Span::raw("User: "),
880 Span::styled(username, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
881 ]));
882 content.push(Line::from(""));
883 }
884
885 match &app.certificates {
886 Some(certs) if !certs.is_empty() => {
887 content.push(Line::from(Span::styled(
888 format!("Found {} certificate(s):", certs.len()),
889 Style::default().fg(Color::Green),
890 )));
891 content.push(Line::from(""));
892
893 for (idx, cert) in certs.iter().enumerate() {
894 content.push(Line::from(Span::styled(
895 format!("Certificate #{}:", idx + 1),
896 Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
897 )));
898 content.push(Line::from(vec![
899 Span::raw(" Serial: "),
900 Span::styled(&cert.serial, Style::default().fg(Color::White)),
901 ]));
902
903 let issued_str = if cert.issued_at > 0 {
905 use chrono::{DateTime, Utc};
906 DateTime::<Utc>::from_timestamp(cert.issued_at, 0)
907 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
908 .unwrap_or_else(|| format!("{}", cert.issued_at))
909 } else {
910 "N/A".to_string()
911 };
912 content.push(Line::from(vec![
913 Span::raw(" Issued At: "),
914 Span::styled(issued_str, Style::default().fg(Color::Green)),
915 ]));
916
917 let expires_str = if cert.expires_at > 0 {
919 use chrono::{DateTime, Utc};
920 DateTime::<Utc>::from_timestamp(cert.expires_at, 0)
921 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
922 .unwrap_or_else(|| format!("{}", cert.expires_at))
923 } else {
924 "N/A".to_string()
925 };
926 content.push(Line::from(vec![
927 Span::raw(" Expires At: "),
928 Span::styled(expires_str, Style::default().fg(Color::Green)),
929 ]));
930
931 let status_color = match cert.status.as_str() {
933 "valid" => Color::Green,
934 "revoked" => Color::Red,
935 "expired" => Color::Yellow,
936 _ => Color::Gray,
937 };
938 content.push(Line::from(vec![
939 Span::raw(" Status: "),
940 Span::styled(&cert.status, Style::default().fg(status_color).add_modifier(Modifier::BOLD)),
941 ]));
942
943 if cert.status == "revoked" {
945 if let Some(revoked_at) = cert.revoked_at {
946 let revoked_str = {
947 use chrono::{DateTime, Utc};
948 DateTime::<Utc>::from_timestamp(revoked_at, 0)
949 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
950 .unwrap_or_else(|| format!("{}", revoked_at))
951 };
952 content.push(Line::from(vec![
953 Span::raw(" Revoked At: "),
954 Span::styled(revoked_str, Style::default().fg(Color::Red)),
955 ]));
956 }
957 if let Some(ref revoked_by) = cert.revoked_by {
958 content.push(Line::from(vec![
959 Span::raw(" Revoked By: "),
960 Span::styled(revoked_by, Style::default().fg(Color::Red)),
961 ]));
962 }
963 if let Some(ref reason) = cert.revoked_reason {
964 content.push(Line::from(vec![
965 Span::raw(" Reason: "),
966 Span::styled(reason, Style::default().fg(Color::Red)),
967 ]));
968 }
969 }
970
971 content.push(Line::from(""));
972 }
973
974 content.push(Line::from(Span::styled(
975 "Press Esc to return to menu",
976 Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC),
977 )));
978 }
979 Some(_) => {
980 content.push(Line::from(Span::styled(
981 "No certificates found for this user",
982 Style::default().fg(Color::Yellow),
983 )));
984 content.push(Line::from(""));
985 content.push(Line::from(Span::styled(
986 "Press Esc to return to menu",
987 Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC),
988 )));
989 }
990 None => {
991 if let Some(user) = app.admin_get_selected_user() {
992 content.push(Line::from(vec![
993 Span::raw("Selected user: "),
994 Span::styled(&user.username, Style::default().fg(Color::Cyan)),
995 ]));
996 content.push(Line::from(""));
997 content.push(Line::from(Span::styled(
998 "Press Enter to load certificates, or Esc to cancel",
999 Style::default().fg(Color::White),
1000 )));
1001 } else {
1002 content.push(Line::from(Span::styled(
1003 "No user selected",
1004 Style::default().fg(Color::Red),
1005 )));
1006 content.push(Line::from(""));
1007 content.push(Line::from(Span::styled(
1008 "Press Esc to return to menu",
1009 Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC),
1010 )));
1011 }
1012 }
1013 }
1014
1015 if let Some(ref status) = app.status_message {
1016 content.push(Line::from(""));
1017 content.push(Line::from(Span::styled(
1018 status.clone(),
1019 Style::default()
1020 .fg(Color::LightCyan)
1021 .add_modifier(Modifier::ITALIC),
1022 )));
1023 }
1024
1025 let paragraph = Paragraph::new(content)
1026 .block(Block::default().borders(Borders::ALL).title("Certificate Management"))
1027 .style(Style::default().fg(Color::White))
1028 .wrap(Wrap { trim: false })
1029 .scroll((app.admin_scroll_offset, 0));
1030
1031 f.render_widget(paragraph, area);
1032}
1033
1034fn render_admin_form(f: &mut Frame, app: &App, area: Rect) {
1035 use AdminField::*;
1036
1037 let block = Block::default()
1038 .borders(Borders::ALL)
1039 .title("Register New User");
1040 let inner = block.inner(area);
1041 f.render_widget(block, area);
1042
1043 let sections = Layout::default()
1044 .direction(Direction::Vertical)
1045 .constraints([
1046 Constraint::Length(3), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(2), Constraint::Length(1), Constraint::Min(0), ])
1054 .split(inner);
1055
1056 let instruction_text = vec![
1057 Line::from("Provide the user's GPG fingerprint and the path to their public key."),
1058 Line::from("Press Tab to switch fields | Enter to submit | Esc to clear | Ctrl+Shift+L to load user list"),
1059 ];
1060
1061 let instructions = Paragraph::new(instruction_text)
1062 .wrap(Wrap { trim: true })
1063 .style(Style::default().fg(Color::White));
1064 f.render_widget(instructions, sections[0]);
1065
1066 let fp_value = app.admin_form.gpg_fp.as_str();
1068 let fp_cursor = fp_value.chars().count();
1069 let fp_style = if app.admin_form.active_field == GpgFingerprint {
1070 Style::default()
1071 .fg(Color::Cyan)
1072 .add_modifier(Modifier::BOLD)
1073 } else {
1074 Style::default()
1075 };
1076
1077 let fp_label_width = 18; let fp_field_width = sections[1].width.saturating_sub(fp_label_width) as usize;
1079 let fp_scroll = if fp_field_width > 0 && fp_cursor >= fp_field_width {
1080 fp_cursor - fp_field_width + 1
1081 } else {
1082 0
1083 };
1084
1085 let fp_line = Line::from(vec![
1086 Span::styled("GPG Fingerprint: ", Style::default().fg(Color::Yellow)),
1087 Span::styled(fp_value, fp_style),
1088 ]);
1089
1090 let fp_paragraph = Paragraph::new(vec![fp_line])
1091 .style(Style::default().fg(Color::White))
1092 .scroll((0, fp_scroll as u16));
1093 f.render_widget(fp_paragraph, sections[1]);
1094
1095 let key_value = app.admin_form.key_path.as_str();
1097 let key_cursor = key_value.chars().count();
1098 let key_style = if app.admin_form.active_field == KeyPath {
1099 Style::default()
1100 .fg(Color::Cyan)
1101 .add_modifier(Modifier::BOLD)
1102 } else {
1103 Style::default()
1104 };
1105
1106 let key_label_width = 11; let key_field_width = sections[2].width.saturating_sub(key_label_width) as usize;
1108 let key_scroll = if key_field_width > 0 && key_cursor >= key_field_width {
1109 key_cursor - key_field_width + 1
1110 } else {
1111 0
1112 };
1113
1114 let key_line = Line::from(vec![
1115 Span::styled("Key Path: ", Style::default().fg(Color::Yellow)),
1116 Span::styled(key_value, key_style),
1117 ]);
1118
1119 let key_paragraph = Paragraph::new(vec![key_line])
1120 .style(Style::default().fg(Color::White))
1121 .scroll((0, key_scroll as u16));
1122 f.render_widget(key_paragraph, sections[2]);
1123
1124 let meta_value = app.admin_form.metadata.as_str();
1126 let meta_cursor = meta_value.chars().count();
1127 let meta_style = if app.admin_form.active_field == Metadata {
1128 Style::default()
1129 .fg(Color::Cyan)
1130 .add_modifier(Modifier::BOLD)
1131 } else {
1132 Style::default()
1133 };
1134
1135 let meta_label_width = 11; let meta_field_width = sections[3].width.saturating_sub(meta_label_width) as usize;
1137 let meta_scroll = if meta_field_width > 0 && meta_cursor >= meta_field_width {
1138 meta_cursor - meta_field_width + 1
1139 } else {
1140 0
1141 };
1142
1143 let meta_line = Line::from(vec![
1144 Span::styled("Metadata: ", Style::default().fg(Color::Yellow)),
1145 Span::styled(meta_value, meta_style),
1146 ]);
1147
1148 let meta_paragraph = Paragraph::new(vec![meta_line])
1149 .style(Style::default().fg(Color::White))
1150 .scroll((0, meta_scroll as u16));
1151 f.render_widget(meta_paragraph, sections[3]);
1152
1153 let help_text = vec![
1155 Line::from(Span::styled(
1156 " (Optional) Example: --name Bob Smith --team Engineering --birthdate 24 Dec",
1157 Style::default()
1158 .fg(Color::DarkGray)
1159 .add_modifier(Modifier::ITALIC),
1160 )),
1161 ];
1162 let help_para = Paragraph::new(help_text);
1163 f.render_widget(help_para, sections[4]);
1164
1165 let hint_line = if app.admin_form_complete() {
1166 Line::from(Span::styled(
1167 "Press Enter to register user",
1168 Style::default()
1169 .fg(Color::LightGreen)
1170 .add_modifier(Modifier::ITALIC),
1171 ))
1172 } else {
1173 Line::from(Span::styled(
1174 "GPG Fingerprint and Key Path are required before submitting",
1175 Style::default()
1176 .fg(Color::DarkGray)
1177 .add_modifier(Modifier::ITALIC),
1178 ))
1179 };
1180 let hint = Paragraph::new(vec![hint_line]).wrap(Wrap { trim: true });
1181 f.render_widget(hint, sections[5]);
1182
1183 if let Some(status) = &app.status_message {
1184 let status_para = Paragraph::new(vec![Line::from(Span::styled(
1185 status.clone(),
1186 Style::default()
1187 .fg(Color::LightCyan)
1188 .add_modifier(Modifier::ITALIC),
1189 ))])
1190 .wrap(Wrap { trim: true });
1191 f.render_widget(status_para, sections[6]);
1192 }
1193
1194 match app.admin_form.active_field {
1196 GpgFingerprint => {
1197 let visible_cursor = fp_cursor.saturating_sub(fp_scroll);
1198 let cursor_x = sections[1].x + fp_label_width as u16 + visible_cursor.min(fp_field_width) as u16;
1199 let cursor_y = sections[1].y;
1200 f.set_cursor_position((cursor_x, cursor_y));
1201 }
1202 KeyPath => {
1203 let visible_cursor = key_cursor.saturating_sub(key_scroll);
1204 let cursor_x = sections[2].x + key_label_width as u16 + visible_cursor.min(key_field_width) as u16;
1205 let cursor_y = sections[2].y;
1206 f.set_cursor_position((cursor_x, cursor_y));
1207 }
1208 Metadata => {
1209 let visible_cursor = meta_cursor.saturating_sub(meta_scroll);
1210 let cursor_x = sections[3].x + meta_label_width as u16 + visible_cursor.min(meta_field_width) as u16;
1211 let cursor_y = sections[3].y;
1212 f.set_cursor_position((cursor_x, cursor_y));
1213 }
1214 }
1215}
1216
1217fn format_timestamp(timestamp: u64) -> String {
1219 use std::time::{Duration, SystemTime, UNIX_EPOCH};
1220
1221 let datetime = UNIX_EPOCH + Duration::from_secs(timestamp);
1222 let now = SystemTime::now();
1223
1224 if let Ok(duration) = now.duration_since(datetime) {
1225 let secs = duration.as_secs();
1226 if secs < 60 {
1227 format!("{}s ago", secs)
1228 } else if secs < 3600 {
1229 format!("{}m ago", secs / 60)
1230 } else if secs < 86400 {
1231 format!("{}h ago", secs / 3600)
1232 } else if secs < 2592000 {
1233 format!("{}d ago", secs / 86400)
1234 } else {
1235 format!("{}mo ago", secs / 2592000)
1236 }
1237 } else {
1238 "future".to_string()
1239 }
1240}
1241
1242fn render_status_tab(f: &mut Frame, app: &App, area: Rect) {
1243 let chunks = Layout::default()
1245 .direction(Direction::Vertical)
1246 .constraints([
1247 Constraint::Length(10), Constraint::Min(10), ])
1250 .split(area);
1251
1252 let connection_icon = match app.connection_status {
1254 crate::erlang::ConnectionStatus::Connected => "🟢",
1255 crate::erlang::ConnectionStatus::Connecting => "🟡",
1256 crate::erlang::ConnectionStatus::Disconnected => "⚫",
1257 crate::erlang::ConnectionStatus::Error => "🔴",
1258 };
1259
1260 let node_info = if let Some(ref node_name) = app.erlang_node_name {
1261 format!("{} {}", connection_icon, node_name)
1262 } else {
1263 format!("{} Not configured", connection_icon)
1264 };
1265
1266 let status_info = vec![
1267 Line::from(Span::styled(
1268 "Connection Status",
1269 Style::default().add_modifier(Modifier::BOLD),
1270 )),
1271 Line::from(""),
1272 Line::from(format!(
1273 "Mode: {}",
1274 if app.is_mock_mode {
1275 "Mock Data"
1276 } else {
1277 "Live"
1278 }
1279 )),
1280 Line::from(format!("Erlang node: {}", node_info)),
1281 Line::from(format!("Status: {}", app.connection_status.as_str())),
1282 Line::from(""),
1283 Line::from(format!(
1284 "Users loaded: {} | Active conversations: {}",
1285 app.users.len(),
1286 app.messages.len()
1287 )),
1288 ];
1289
1290 let connection_paragraph = Paragraph::new(status_info)
1291 .block(Block::default().borders(Borders::ALL).title("Connection"))
1292 .style(Style::default().fg(Color::White));
1293
1294 f.render_widget(connection_paragraph, chunks[0]);
1295
1296 let mut engine_info = vec![];
1298
1299 if let Some(ref status) = app.engine_status {
1301 if let Some(expires_at) = status.cert_expires_at {
1302 engine_info.push(Line::from(Span::styled(
1303 "Certificate Status",
1304 Style::default()
1305 .add_modifier(Modifier::BOLD)
1306 .fg(Color::Magenta),
1307 )));
1308 engine_info.push(Line::from(""));
1309
1310 let expires_date = chrono::DateTime::from_timestamp(expires_at, 0)
1312 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
1313 .unwrap_or_else(|| "Unknown".to_string());
1314
1315 let days_until_expiry = if let Some(seconds) = status.cert_time_until_expiry {
1317 seconds / 86400
1318 } else {
1319 0
1320 };
1321
1322 let expiry_color = if days_until_expiry < 1 {
1323 Color::Red
1324 } else if days_until_expiry < 3 {
1325 Color::Yellow
1326 } else {
1327 Color::Green
1328 };
1329
1330 let status_icon = if days_until_expiry < 1 {
1331 "🔴"
1332 } else if days_until_expiry < 3 {
1333 "🟡"
1334 } else {
1335 "🟢"
1336 };
1337
1338 engine_info.push(Line::from(vec![
1339 Span::raw(format!("{} ", status_icon)),
1340 Span::styled("Expires: ", Style::default().fg(Color::Yellow)),
1341 Span::styled(expires_date, Style::default().fg(expiry_color)),
1342 Span::raw(format!(" ({} days)", days_until_expiry)),
1343 ]));
1344
1345 if status.cert_renewal_in_progress {
1346 engine_info.push(Line::from(Span::styled(
1347 "⏳ Renewal in progress...",
1348 Style::default().fg(Color::Cyan).add_modifier(Modifier::ITALIC),
1349 )));
1350 } else {
1351 engine_info.push(Line::from(Span::styled(
1352 "Press 'n' to renew certificate now",
1353 Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC),
1354 )));
1355 }
1356
1357 engine_info.push(Line::from(""));
1358 engine_info.push(Line::from(""));
1359 }
1360 }
1361
1362 if let Some(ref status) = app.engine_status {
1363 engine_info.push(Line::from(Span::styled(
1364 "Cryptic Engine Status",
1365 Style::default()
1366 .add_modifier(Modifier::BOLD)
1367 .fg(Color::Cyan),
1368 )));
1369 engine_info.push(Line::from(""));
1370
1371 let uptime_str = format_uptime(status.uptime_ms);
1373
1374 engine_info.push(Line::from(vec![
1375 Span::styled("Username: ", Style::default().fg(Color::Yellow)),
1376 Span::raw(status.username.clone()),
1377 Span::raw(" | "),
1378 Span::styled("Uptime: ", Style::default().fg(Color::Yellow)),
1379 Span::raw(uptime_str),
1380 ]));
1381
1382 engine_info.push(Line::from(vec![
1383 Span::styled("Active Sessions: ", Style::default().fg(Color::Yellow)),
1384 Span::raw(format!("{}", status.active_sessions)),
1385 Span::raw(" | "),
1386 Span::styled("Messages: ", Style::default().fg(Color::Yellow)),
1387 Span::raw(format!("{}", status.message_count)),
1388 Span::raw(" | "),
1389 Span::styled("Errors: ", Style::default().fg(Color::Yellow)),
1390 Span::raw(format!("{}", status.error_count)),
1391 ]));
1392
1393 engine_info.push(Line::from(""));
1394
1395 if !status.session_details.is_empty() {
1396 engine_info.push(Line::from(Span::styled(
1397 "Double Ratchet Sessions:",
1398 Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
1399 )));
1400 engine_info.push(Line::from(""));
1401
1402 engine_info.push(Line::from(vec![
1404 Span::styled(
1405 format!("{:<12}", "Peer"),
1406 Style::default()
1407 .add_modifier(Modifier::BOLD)
1408 .fg(Color::Green),
1409 ),
1410 Span::styled(
1411 format!("{:>6}", "Step"),
1412 Style::default()
1413 .add_modifier(Modifier::BOLD)
1414 .fg(Color::Green),
1415 ),
1416 Span::styled(
1417 format!("{:>6}", "Send"),
1418 Style::default()
1419 .add_modifier(Modifier::BOLD)
1420 .fg(Color::Green),
1421 ),
1422 Span::styled(
1423 format!("{:>6}", "Recv"),
1424 Style::default()
1425 .add_modifier(Modifier::BOLD)
1426 .fg(Color::Green),
1427 ),
1428 Span::styled(
1429 format!("{:>6}", "Prev"),
1430 Style::default()
1431 .add_modifier(Modifier::BOLD)
1432 .fg(Color::Green),
1433 ),
1434 Span::styled(
1435 format!("{:>9}", "Skipped"),
1436 Style::default()
1437 .add_modifier(Modifier::BOLD)
1438 .fg(Color::Green),
1439 ),
1440 Span::raw(" "),
1441 Span::styled(
1442 "State",
1443 Style::default()
1444 .add_modifier(Modifier::BOLD)
1445 .fg(Color::Green),
1446 ),
1447 ]));
1448
1449 for session in &status.session_details {
1451 engine_info.push(Line::from(vec![
1452 Span::raw(format!(
1453 "{:<12}",
1454 truncate_string(&session.peer_username, 12)
1455 )),
1456 Span::raw(format!("{:>6}", session.dh_ratchet_step)),
1457 Span::raw(format!("{:>6}", session.send_msg_number)),
1458 Span::raw(format!("{:>6}", session.recv_msg_number)),
1459 Span::raw(format!("{:>6}", session.prev_recv_chain_length)),
1460 Span::raw(format!("{:>9}", session.skipped_keys_count)),
1461 Span::raw(" "),
1462 Span::styled(
1463 &session.current_state,
1464 if session.current_state.contains("error") {
1465 Style::default().fg(Color::Red)
1466 } else if session.receiving_chain_active || session.sending_chain_active {
1467 Style::default().fg(Color::Green)
1468 } else {
1469 Style::default().fg(Color::Gray)
1470 },
1471 ),
1472 ]));
1473 }
1474
1475 engine_info.push(Line::from(""));
1477 engine_info.push(Line::from(Span::styled(
1478 "Legend:",
1479 Style::default()
1480 .fg(Color::Gray)
1481 .add_modifier(Modifier::ITALIC),
1482 )));
1483 engine_info.push(Line::from(Span::styled(
1484 " Step: DH ratchet steps (key rotations) that have occurred",
1485 Style::default()
1486 .fg(Color::DarkGray)
1487 .add_modifier(Modifier::ITALIC),
1488 )));
1489 engine_info.push(Line::from(Span::styled(
1490 " Send/Recv: Current chain message counters (reset on DH step)",
1491 Style::default()
1492 .fg(Color::DarkGray)
1493 .add_modifier(Modifier::ITALIC),
1494 )));
1495 engine_info.push(Line::from(Span::styled(
1496 " Prev: Messages from the previous receiving chain",
1497 Style::default()
1498 .fg(Color::DarkGray)
1499 .add_modifier(Modifier::ITALIC),
1500 )));
1501 engine_info.push(Line::from(Span::styled(
1502 " Skipped: Out-of-order messages cached for delayed delivery",
1503 Style::default()
1504 .fg(Color::DarkGray)
1505 .add_modifier(Modifier::ITALIC),
1506 )));
1507 } else {
1508 engine_info.push(Line::from(Span::styled(
1509 "No active sessions",
1510 Style::default()
1511 .fg(Color::DarkGray)
1512 .add_modifier(Modifier::ITALIC),
1513 )));
1514 }
1515
1516 engine_info.push(Line::from(""));
1517 engine_info.push(Line::from(Span::styled(
1518 "Press 'r' to refresh engine status",
1519 Style::default().fg(Color::Gray),
1520 )));
1521 } else {
1522 engine_info.push(Line::from(Span::styled(
1523 "Engine Status",
1524 Style::default()
1525 .add_modifier(Modifier::BOLD)
1526 .fg(Color::Cyan),
1527 )));
1528 engine_info.push(Line::from(""));
1529 engine_info.push(Line::from(Span::styled(
1530 "No engine status available. Press 'r' to refresh.",
1531 Style::default().fg(Color::Yellow),
1532 )));
1533 }
1534
1535 let engine_paragraph = Paragraph::new(engine_info)
1536 .block(
1537 Block::default()
1538 .borders(Borders::ALL)
1539 .title("System Status"),
1540 )
1541 .style(Style::default().fg(Color::White))
1542 .scroll((app.status_scroll_offset, 0));
1543
1544 f.render_widget(engine_paragraph, chunks[1]);
1545}
1546
1547fn format_uptime(uptime_us: u64) -> String {
1549 let seconds = uptime_us / 1_000_000;
1550 let minutes = seconds / 60;
1551 let hours = minutes / 60;
1552 let days = hours / 24;
1553
1554 if days > 0 {
1555 format!("{}d {}h", days, hours % 24)
1556 } else if hours > 0 {
1557 format!("{}h {}m", hours, minutes % 60)
1558 } else if minutes > 0 {
1559 format!("{}m {}s", minutes, seconds % 60)
1560 } else {
1561 format!("{}s", seconds)
1562 }
1563}
1564
1565fn truncate_string(s: &str, max_len: usize) -> String {
1567 if s.len() <= max_len {
1568 s.to_string()
1569 } else if max_len > 3 {
1570 format!("{}...", &s[..max_len - 3])
1571 } else {
1572 s[..max_len].to_string()
1573 }
1574}
1575
1576fn render_help_tab(f: &mut Frame, app: &App, area: Rect) {
1577 let help_text = vec![
1578 Line::from(Span::styled(
1579 "Keyboard Shortcuts",
1580 Style::default().add_modifier(Modifier::BOLD),
1581 )),
1582 Line::from(""),
1583 Line::from("Global:"),
1584 Line::from(" Ctrl+Q - Quit application"),
1585 Line::from(" Tab - Next tab"),
1586 Line::from(" Shift+Tab - Previous tab"),
1587 Line::from(""),
1588 Line::from("Chat Tab:"),
1589 Line::from(" ↑/↓ - Navigate user list"),
1590 Line::from(" Enter - Select user / Send message"),
1591 Line::from(" Ctrl+U - Scroll up messages (or PgUp / Fn+↑)"),
1592 Line::from(" Ctrl+D - Scroll down messages (or PgDn / Fn+↓)"),
1593 Line::from(" Home - Jump to beginning of conversation"),
1594 Line::from(" End - Jump to latest messages"),
1595 Line::from(" Esc - Clear input"),
1596 Line::from(""),
1597 Line::from("Text Editing (Emacs-style):"),
1598 Line::from(" Ctrl+A - Move to beginning of line"),
1599 Line::from(" Ctrl+E - Move to end of line"),
1600 Line::from(" Ctrl+B - Move cursor left (or ←)"),
1601 Line::from(" Ctrl+F - Move cursor right (or →)"),
1602 Line::from(" Backspace - Delete before cursor"),
1603 Line::from(" Delete - Delete at cursor"),
1604 Line::from(""),
1605 Line::from("Status Tab:"),
1606 Line::from(" r - Refresh engine status"),
1607 Line::from(" n - Renew certificate now"),
1608 Line::from(""),
1609 Line::from("Admin Tab:"),
1610 Line::from(" Ctrl+Shift+L - Load/refresh detailed user list"),
1611 Line::from(" Tab/↑/↓ - Switch between register form fields"),
1612 Line::from(" Enter - Submit new user registration"),
1613 Line::from(" Esc - Clear register form"),
1614 Line::from(" Ctrl+H/L - Navigate to previous/next tab"),
1615 Line::from(" r - Revoke user (coming soon)"),
1616 Line::from(" s - Suspend user (coming soon)"),
1617 Line::from(""),
1618 Line::from(Span::styled(
1619 "MacBook Users: Use Ctrl+U/D for scrolling (easier than Fn+↑/↓)",
1620 Style::default().fg(Color::Yellow),
1621 )),
1622 ];
1623
1624 let paragraph = Paragraph::new(help_text)
1625 .block(Block::default().borders(Borders::ALL).title("Help"))
1626 .style(Style::default().fg(Color::White))
1627 .scroll((app.help_scroll_offset, 0));
1628
1629 f.render_widget(paragraph, area);
1630}
1631
1632fn render_splash(f: &mut Frame, app: &App, area: Rect) {
1633 let outer = Layout::default()
1635 .direction(Direction::Vertical)
1636 .constraints([
1637 Constraint::Percentage(30),
1638 Constraint::Percentage(40),
1639 Constraint::Percentage(30),
1640 ])
1641 .split(area);
1642
1643 let inner = Layout::default()
1644 .direction(Direction::Horizontal)
1645 .constraints([
1646 Constraint::Percentage(25),
1647 Constraint::Percentage(50),
1648 Constraint::Percentage(25),
1649 ])
1650 .split(outer[1]);
1651
1652 let panel_area = inner[1];
1653 let title = Span::styled(
1654 "CRYPTIC TERMINAL UI",
1655 Style::default()
1656 .fg(Color::Cyan)
1657 .add_modifier(Modifier::BOLD),
1658 );
1659
1660 let masked = if app.passphrase_input.is_empty() {
1661 "".to_string()
1662 } else {
1663 "*".repeat(app.passphrase_input.chars().count())
1664 };
1665
1666 let mut lines: Vec<Line> = vec![
1667 Line::from(title),
1668 Line::from(""),
1669 Line::from("Secure messaging interface"),
1670 Line::from(""),
1671 Line::from(Span::styled(
1672 "Enter passphrase to unlock local key material",
1673 Style::default().fg(Color::White),
1674 )),
1675 Line::from(""),
1676 Line::from(format!("Passphrase: {}", masked)),
1677 Line::from(""),
1678 Line::from("Enter continue | Esc clear | Ctrl+Q quit"),
1679 ];
1680
1681 if let Some(err) = &app.splash_error {
1682 lines.push(Line::from(Span::styled(
1683 err,
1684 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1685 )));
1686 }
1687
1688 let block = Block::default()
1689 .title("Unlock")
1690 .borders(Borders::ALL)
1691 .border_style(Style::default().fg(Color::Cyan));
1692
1693 let paragraph = Paragraph::new(lines)
1694 .block(block)
1695 .alignment(ratatui::layout::Alignment::Center);
1696
1697 f.render_widget(paragraph, panel_area);
1698}
1699
1700fn render_status_bar(f: &mut Frame, app: &App, area: Rect) {
1701 let text = if let Some(msg) = app.get_current_system_message() {
1702 Span::styled(
1704 format!(" ℹ {}", msg),
1705 Style::default()
1706 .fg(Color::Black)
1707 .bg(Color::LightCyan)
1708 .add_modifier(Modifier::BOLD),
1709 )
1710 } else {
1711 let (icon, status_text) = if app.server_connection_up {
1713 ("🟢", "Server up!")
1714 } else {
1715 ("🔴", "Server down!")
1716 };
1717
1718 Span::styled(
1719 format!(" {} {}", icon, status_text),
1720 Style::default()
1721 .fg(Color::White)
1722 .bg(Color::DarkGray),
1723 )
1724 };
1725
1726 let paragraph = Paragraph::new(Line::from(text));
1727 f.render_widget(paragraph, area);
1728}