login page now in a separate dir

This commit is contained in:
filipriec
2025-08-23 19:48:23 +02:00
parent d5cfe59f47
commit f56092e86c
11 changed files with 54 additions and 14 deletions

View File

@@ -0,0 +1,239 @@
// src/tui/functions/common/login.rs
use crate::services::auth::AuthClient;
use crate::state::pages::auth::AuthState;
use crate::state::pages::auth::LoginState;
use crate::state::app::state::AppState;
use crate::buffer::state::{AppView, BufferState};
use crate::config::storage::storage::{StoredAuthData, save_auth_data};
use crate::ui::handlers::context::DialogPurpose;
use common::proto::komp_ac::auth::LoginResponse;
use anyhow::{Context, Result};
use tokio::spawn;
use tokio::sync::mpsc;
use tracing::{info, error};
#[derive(Debug)]
pub enum LoginResult {
Success(LoginResponse),
Failure(String),
ConnectionError(String),
}
/// Attempts to log the user in using the provided credentials via gRPC.
/// Updates AuthState and AppState on success or failure.
pub async fn save(
auth_state: &mut AuthState,
login_state: &mut LoginState,
auth_client: &mut AuthClient,
app_state: &mut AppState,
) -> Result<String> {
let identifier = login_state.username.clone();
let password = login_state.password.clone();
// --- Client-side validation ---
// Prevent login attempt if the identifier field is empty or whitespace.
if identifier.trim().is_empty() {
let error_message = "Username/Email cannot be empty.".to_string();
app_state.show_dialog(
"Login Failed",
&error_message,
vec!["OK".to_string()],
DialogPurpose::LoginFailed,
);
login_state.error_message = Some(error_message.clone());
return Err(anyhow::anyhow!(error_message));
}
// Clear previous error/dialog state before attempting
login_state.error_message = None;
app_state.hide_dialog(); // Hide any previous dialog
// Call the gRPC login method
match auth_client.login(identifier.clone(), password).await
.with_context(|| format!("gRPC login attempt failed for identifier: {}", identifier))
{
Ok(response) => {
// Store authentication details using correct field names
auth_state.auth_token = Some(response.access_token.clone());
auth_state.user_id = Some(response.user_id.clone());
auth_state.role = Some(response.role.clone());
auth_state.decoded_username = Some(response.username.clone());
login_state.set_has_unsaved_changes(false);
login_state.error_message = None;
// Format the success message using response data
let success_message = format!(
"Login Successful!\n\n\
Username: {}\n\
User ID: {}\n\
Role: {}",
response.username,
response.user_id,
response.role
);
app_state.show_dialog(
"Login Success",
&success_message,
vec!["Menu".to_string(), "Exit".to_string()],
DialogPurpose::LoginSuccess,
);
login_state.password.clear();
login_state.username.clear();
login_state.current_cursor_pos = 0;
Ok("Login successful, details shown in dialog.".to_string())
}
Err(e) => {
let error_message = format!("{}", e);
app_state.show_dialog(
"Login Failed",
&error_message,
vec!["OK".to_string()],
DialogPurpose::LoginFailed,
);
login_state.error_message = Some(error_message.clone());
login_state.set_has_unsaved_changes(true);
login_state.username.clear();
login_state.password.clear();
Err(e)
}
}
}
/// Reverts the login form fields to empty and returns to the previous screen (Intro).
pub async fn revert(
login_state: &mut LoginState,
_app_state: &mut AppState, // Keep signature consistent if needed elsewhere
) -> String {
// Clear the input fields
login_state.username.clear();
login_state.password.clear();
login_state.error_message = None;
login_state.set_has_unsaved_changes(false);
login_state.login_request_pending = false; // Ensure flag is reset on revert
"Login reverted".to_string()
}
pub async fn back_to_main(
login_state: &mut LoginState,
app_state: &mut AppState,
buffer_state: &mut BufferState,
) -> String {
// Clear the input fields
login_state.username.clear();
login_state.password.clear();
login_state.error_message = None;
login_state.set_has_unsaved_changes(false);
login_state.login_request_pending = false; // Ensure flag is reset
// Ensure dialog is hidden if revert is called
app_state.hide_dialog();
// Navigation logic
buffer_state.close_active_buffer();
buffer_state.update_history(AppView::Intro);
// Reset focus state
app_state.ui.focus_outside_canvas = false;
app_state.focused_button_index= 0;
"Returned to main menu".to_string()
}
/// Validates input, shows loading, and spawns the login task.
pub fn initiate_login(
login_state: &LoginState,
app_state: &mut AppState,
mut auth_client: AuthClient,
sender: mpsc::Sender<LoginResult>,
) -> String {
let username = login_state.username.clone();
let password = login_state.password.clone();
// 1. Client-side validation
if username.trim().is_empty() {
app_state.show_dialog(
"Login Failed",
"Username/Email cannot be empty.",
vec!["OK".to_string()],
DialogPurpose::LoginFailed,
);
"Username cannot be empty.".to_string()
} else {
// 2. Show Loading Dialog
app_state.show_loading_dialog("Logging In", "Please wait...");
// 3. Spawn the login task
spawn(async move {
// Use the passed-in (and moved) auth_client directly
let login_outcome = match auth_client.login(username.clone(), password).await
.with_context(|| format!("Spawned login task failed for identifier: {}", username))
{
Ok(response) => LoginResult::Success(response),
Err(e) => LoginResult::Failure(format!("{}", e)),
};
// Send result back to the main UI thread
if let Err(e) = sender.send(login_outcome).await {
error!("Failed to send login result: {}", e);
}
});
// 4. Return immediately
"Login initiated.".to_string()
}
}
/// Handles the result received from the login task.
/// Returns true if a redraw is needed.
pub fn handle_login_result(
result: LoginResult,
app_state: &mut AppState,
auth_state: &mut AuthState,
login_state: &mut LoginState,
) -> bool {
match result {
LoginResult::Success(response) => {
auth_state.auth_token = Some(response.access_token.clone());
auth_state.user_id = Some(response.user_id.clone());
auth_state.role = Some(response.role.clone());
auth_state.decoded_username = Some(response.username.clone());
// --- NEW: Save auth data to file ---
let data_to_store = StoredAuthData {
access_token: response.access_token.clone(),
user_id: response.user_id.clone(),
role: response.role.clone(),
username: response.username.clone(),
};
if let Err(e) = save_auth_data(&data_to_store) {
error!("Failed to save auth data to file: {}", e);
// Continue anyway - user is still logged in for this session
}
// --- END NEW ---
let success_message = format!(
"Login Successful!\n\nUsername: {}\nUser ID: {}\nRole: {}",
response.username, response.user_id, response.role
);
app_state.update_dialog_content(
&success_message,
vec!["Menu".to_string(), "Exit".to_string()],
DialogPurpose::LoginSuccess,
);
info!(message = %success_message, "Login successful");
}
LoginResult::Failure(err_msg) | LoginResult::ConnectionError(err_msg) => {
app_state.update_dialog_content(&err_msg, vec!["OK".to_string()], DialogPurpose::LoginFailed);
login_state.error_message = Some(err_msg.clone());
error!(error = %err_msg, "Login failed/connection error");
}
}
login_state.username.clear();
login_state.password.clear();
login_state.set_has_unsaved_changes(false);
login_state.current_cursor_pos = 0;
true // Request redraw as dialog content changed
}

View File

@@ -0,0 +1,9 @@
// src/pages/login/mod.rs
pub mod state;
pub mod ui;
pub mod logic;
pub use state::LoginState;
pub use ui::render_login;
pub use logic::*;

View File

@@ -0,0 +1,30 @@
// src/pages/login/state.rs
use canvas::AppMode;
#[derive(Debug, Clone)]
pub struct LoginState {
pub username: String,
pub password: String,
pub error_message: Option<String>,
pub current_field: usize,
pub current_cursor_pos: usize,
pub has_unsaved_changes: bool,
pub login_request_pending: bool,
pub app_mode: AppMode,
}
impl Default for LoginState {
fn default() -> Self {
Self {
username: String::new(),
password: String::new(),
error_message: None,
current_field: 0,
current_cursor_pos: 0,
has_unsaved_changes: false,
login_request_pending: false,
app_mode: AppMode::Edit,
}
}
}

View File

@@ -0,0 +1,163 @@
// src/pages/login/ui.rs
use crate::{
config::colors::themes::Theme,
state::pages::auth::LoginState,
state::app::state::AppState,
};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect, Margin},
style::{Style, Modifier, Color},
widgets::{Block, BorderType, Borders, Paragraph},
Frame,
};
use canvas::{
FormEditor,
render_canvas,
render_suggestions_dropdown,
DefaultCanvasTheme,
};
use crate::dialog;
pub fn render_login(
f: &mut Frame,
area: Rect,
theme: &Theme,
// FIX: take &LoginState (reference), not owned
login_state: &LoginState,
app_state: &AppState,
is_edit_mode: bool,
) {
// Main container
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(Style::default().fg(theme.border))
.title(" Login ")
.style(Style::default().bg(theme.bg));
f.render_widget(block, area);
let inner_area = area.inner(Margin {
horizontal: 1,
vertical: 1,
});
// Layout chunks
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(4), // Form (2 fields + padding)
Constraint::Length(1), // Error message
Constraint::Length(3), // Buttons
])
.split(inner_area);
// Wrap LoginState in FormEditor (no clone needed)
let editor = FormEditor::new(login_state.clone());
// Use DefaultCanvasTheme instead of app Theme
let input_rect = render_canvas(
f,
chunks[0],
&editor,
&DefaultCanvasTheme,
);
// --- ERROR MESSAGE ---
if let Some(err) = &login_state.error_message {
f.render_widget(
Paragraph::new(err.as_str())
.style(Style::default().fg(Color::Red))
.alignment(Alignment::Center),
chunks[1],
);
}
// --- BUTTONS (unchanged) ---
let button_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(chunks[2]);
// Login Button
let login_button_index = 0;
let login_active = if app_state.ui.focus_outside_canvas {
app_state.focused_button_index == login_button_index
} else {
false
};
let mut login_style = Style::default().fg(theme.fg);
let mut login_border = Style::default().fg(theme.border);
if login_active {
login_style = login_style.fg(theme.highlight).add_modifier(Modifier::BOLD);
login_border = login_border.fg(theme.accent);
}
f.render_widget(
Paragraph::new("Login")
.style(login_style)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(login_border),
),
button_chunks[0],
);
// Return Button
let return_button_index = 1;
let return_active = if app_state.ui.focus_outside_canvas {
app_state.focused_button_index == return_button_index
} else {
false
};
let mut return_style = Style::default().fg(theme.fg);
let mut return_border = Style::default().fg(theme.border);
if return_active {
return_style = return_style.fg(theme.highlight).add_modifier(Modifier::BOLD);
return_border = return_border.fg(theme.accent);
}
f.render_widget(
Paragraph::new("Return")
.style(return_style)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(return_border),
),
button_chunks[1],
);
// --- SUGGESTIONS DROPDOWN (if active) ---
if app_state.current_mode == crate::modes::handlers::mode_manager::AppMode::Edit {
if let Some(input_rect) = input_rect {
render_suggestions_dropdown(
f,
f.area(),
input_rect,
&DefaultCanvasTheme,
&editor, // FIX: pass &editor
);
}
}
// --- DIALOG ---
if app_state.ui.dialog.dialog_show {
dialog::render_dialog(
f,
f.area(),
theme,
&app_state.ui.dialog.dialog_title,
&app_state.ui.dialog.dialog_message,
&app_state.ui.dialog.dialog_buttons,
app_state.ui.dialog.dialog_active_button_index,
app_state.ui.dialog.is_loading,
);
}
}

View File

@@ -2,3 +2,4 @@
pub mod routing;
pub mod forms;
pub mod login;