login page using canvas for forms

This commit is contained in:
filipriec
2025-08-28 21:07:23 +02:00
parent 6e221ef8c1
commit 19a9bab8c2
8 changed files with 195 additions and 101 deletions

View File

@@ -34,7 +34,7 @@ impl CommandHandler {
) -> Result<(bool, String)> { ) -> Result<(bool, String)> {
// Use router to check unsaved changes // Use router to check unsaved changes
let has_unsaved = match &router.current { let has_unsaved = match &router.current {
Page::Login(state) => state.has_unsaved_changes(), Page::Login(page) => page.state.has_unsaved_changes(),
Page::Register(state) => state.has_unsaved_changes(), Page::Register(state) => state.has_unsaved_changes(),
Page::Form(fs) => fs.has_unsaved_changes, Page::Form(fs) => fs.has_unsaved_changes,
_ => false, _ => false,

View File

@@ -87,11 +87,11 @@ pub async fn handle_navigation_event(
pub fn up(app_state: &mut AppState, router: &mut Router) { pub fn up(app_state: &mut AppState, router: &mut Router) {
match &mut router.current { match &mut router.current {
Page::Login(state) if app_state.ui.focus_outside_canvas => { Page::Login(page) if app_state.ui.focus_outside_canvas => {
if app_state.focused_button_index == 0 { if app_state.focused_button_index == 0 {
app_state.ui.focus_outside_canvas = false; app_state.ui.focus_outside_canvas = false;
let last_field_index = state.field_count().saturating_sub(1); let last_field_index = page.state.field_count().saturating_sub(1);
state.set_current_field(last_field_index); page.state.set_current_field(last_field_index);
} else { } else {
app_state.focused_button_index = app_state.focused_button_index =
app_state.focused_button_index.saturating_sub(1); app_state.focused_button_index.saturating_sub(1);

View File

@@ -1,4 +1,4 @@
// src/tui/functions/common/login.rs // src/pages/login/logic.rs
use crate::services::auth::AuthClient; use crate::services::auth::AuthClient;
use crate::state::pages::auth::AuthState; use crate::state::pages::auth::AuthState;
@@ -7,12 +7,11 @@ use crate::buffer::state::{AppView, BufferState};
use crate::config::storage::storage::{StoredAuthData, save_auth_data}; use crate::config::storage::storage::{StoredAuthData, save_auth_data};
use crate::ui::handlers::context::DialogPurpose; use crate::ui::handlers::context::DialogPurpose;
use common::proto::komp_ac::auth::LoginResponse; use common::proto::komp_ac::auth::LoginResponse;
use crate::pages::login::LoginState; use crate::pages::login::LoginFormState;
use anyhow::{Context, Result}; use anyhow::{Context, Result, anyhow};
use tokio::spawn; use tokio::spawn;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tracing::{info, error}; use tracing::{info, error};
use anyhow::anyhow;
#[derive(Debug)] #[derive(Debug)]
pub enum LoginResult { pub enum LoginResult {
@@ -25,15 +24,14 @@ pub enum LoginResult {
/// Updates AuthState and AppState on success or failure. /// Updates AuthState and AppState on success or failure.
pub async fn save( pub async fn save(
auth_state: &mut AuthState, auth_state: &mut AuthState,
login_state: &mut LoginState, login_state: &mut LoginFormState,
auth_client: &mut AuthClient, auth_client: &mut AuthClient,
app_state: &mut AppState, app_state: &mut AppState,
) -> Result<String> { ) -> Result<String> {
let identifier = login_state.username.clone(); let identifier = login_state.username().to_string();
let password = login_state.password.clone(); let password = login_state.password().to_string();
// --- Client-side validation --- // --- Client-side validation ---
// Prevent login attempt if the identifier field is empty or whitespace.
if identifier.trim().is_empty() { if identifier.trim().is_empty() {
let error_message = "Username/Email cannot be empty.".to_string(); let error_message = "Username/Email cannot be empty.".to_string();
app_state.show_dialog( app_state.show_dialog(
@@ -42,33 +40,33 @@ pub async fn save(
vec!["OK".to_string()], vec!["OK".to_string()],
DialogPurpose::LoginFailed, DialogPurpose::LoginFailed,
); );
login_state.error_message = Some(error_message.clone()); login_state.set_error_message(Some(error_message.clone()));
return Err(anyhow::anyhow!(error_message)); return Err(anyhow!(error_message));
} }
// Clear previous error/dialog state before attempting // Clear previous error/dialog state before attempting
login_state.error_message = None; login_state.set_error_message(None);
app_state.hide_dialog(); // Hide any previous dialog app_state.hide_dialog();
// Call the gRPC login method // Call the gRPC login method
match auth_client.login(identifier.clone(), password).await match auth_client.login(identifier.clone(), password).await
.with_context(|| format!("gRPC login attempt failed for identifier: {}", identifier)) .with_context(|| format!("gRPC login attempt failed for identifier: {}", identifier))
{ {
Ok(response) => { Ok(response) => {
// Store authentication details using correct field names // Store authentication details
auth_state.auth_token = Some(response.access_token.clone()); auth_state.auth_token = Some(response.access_token.clone());
auth_state.user_id = Some(response.user_id.clone()); auth_state.user_id = Some(response.user_id.clone());
auth_state.role = Some(response.role.clone()); auth_state.role = Some(response.role.clone());
auth_state.decoded_username = Some(response.username.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 login_state.set_has_unsaved_changes(false);
login_state.set_error_message(None);
let success_message = format!( let success_message = format!(
"Login Successful!\n\n\ "Login Successful!\n\n\
Username: {}\n\ Username: {}\n\
User ID: {}\n\ User ID: {}\n\
Role: {}", Role: {}",
response.username, response.username,
response.user_id, response.user_id,
response.role response.role
@@ -80,9 +78,11 @@ pub async fn save(
vec!["Menu".to_string(), "Exit".to_string()], vec!["Menu".to_string(), "Exit".to_string()],
DialogPurpose::LoginSuccess, DialogPurpose::LoginSuccess,
); );
login_state.password.clear();
login_state.username.clear(); login_state.username_mut().clear();
login_state.current_cursor_pos = 0; login_state.password_mut().clear();
login_state.set_current_cursor_pos(0);
Ok("Login successful, details shown in dialog.".to_string()) Ok("Login successful, details shown in dialog.".to_string())
} }
Err(e) => { Err(e) => {
@@ -93,10 +93,10 @@ pub async fn save(
vec!["OK".to_string()], vec!["OK".to_string()],
DialogPurpose::LoginFailed, DialogPurpose::LoginFailed,
); );
login_state.error_message = Some(error_message.clone()); login_state.set_error_message(Some(error_message.clone()));
login_state.set_has_unsaved_changes(true); login_state.set_has_unsaved_changes(true);
login_state.username.clear(); login_state.username_mut().clear();
login_state.password.clear(); login_state.password_mut().clear();
Err(e) Err(e)
} }
} }
@@ -104,56 +104,42 @@ pub async fn save(
/// Reverts the login form fields to empty and returns to the previous screen (Intro). /// Reverts the login form fields to empty and returns to the previous screen (Intro).
pub async fn revert( pub async fn revert(
login_state: &mut LoginState, login_state: &mut LoginFormState,
_app_state: &mut AppState, // Keep signature consistent if needed elsewhere app_state: &mut AppState,
) -> String { ) -> String {
// Clear the input fields login_state.clear();
login_state.username.clear(); app_state.hide_dialog();
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() "Login reverted".to_string()
} }
/// Clears login form and navigates back to main menu.
pub async fn back_to_main( pub async fn back_to_main(
login_state: &mut LoginState, login_state: &mut LoginFormState,
app_state: &mut AppState, app_state: &mut AppState,
buffer_state: &mut BufferState, buffer_state: &mut BufferState,
) -> String { ) -> String {
// Clear the input fields login_state.clear();
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(); app_state.hide_dialog();
// Navigation logic
buffer_state.close_active_buffer(); buffer_state.close_active_buffer();
buffer_state.update_history(AppView::Intro); buffer_state.update_history(AppView::Intro);
// Reset focus state
app_state.ui.focus_outside_canvas = false; app_state.ui.focus_outside_canvas = false;
app_state.focused_button_index= 0; app_state.focused_button_index = 0;
"Returned to main menu".to_string() "Returned to main menu".to_string()
} }
/// Validates input, shows loading, and spawns the login task. /// Validates input, shows loading, and spawns the login task.
pub fn initiate_login( pub fn initiate_login(
login_state: &LoginState, login_state: &LoginFormState,
app_state: &mut AppState, app_state: &mut AppState,
mut auth_client: AuthClient, mut auth_client: AuthClient,
sender: mpsc::Sender<LoginResult>, sender: mpsc::Sender<LoginResult>,
) -> String { ) -> String {
let username = login_state.username.clone(); let username = login_state.username().to_string();
let password = login_state.password.clone(); let password = login_state.password().to_string();
// 1. Client-side validation
if username.trim().is_empty() { if username.trim().is_empty() {
app_state.show_dialog( app_state.show_dialog(
"Login Failed", "Login Failed",
@@ -163,25 +149,20 @@ pub fn initiate_login(
); );
"Username cannot be empty.".to_string() "Username cannot be empty.".to_string()
} else { } else {
// 2. Show Loading Dialog
app_state.show_loading_dialog("Logging In", "Please wait..."); app_state.show_loading_dialog("Logging In", "Please wait...");
// 3. Spawn the login task
spawn(async move { spawn(async move {
// Use the passed-in (and moved) auth_client directly
let login_outcome = match auth_client.login(username.clone(), password).await let login_outcome = match auth_client.login(username.clone(), password).await
.with_context(|| format!("Spawned login task failed for identifier: {}", username)) .with_context(|| format!("Spawned login task failed for identifier: {}", username))
{ {
Ok(response) => LoginResult::Success(response), Ok(response) => LoginResult::Success(response),
Err(e) => LoginResult::Failure(format!("{}", e)), Err(e) => LoginResult::Failure(format!("{}", e)),
}; };
// Send result back to the main UI thread
if let Err(e) = sender.send(login_outcome).await { if let Err(e) = sender.send(login_outcome).await {
error!("Failed to send login result: {}", e); error!("Failed to send login result: {}", e);
} }
}); });
// 4. Return immediately
"Login initiated.".to_string() "Login initiated.".to_string()
} }
} }
@@ -192,7 +173,7 @@ pub fn handle_login_result(
result: LoginResult, result: LoginResult,
app_state: &mut AppState, app_state: &mut AppState,
auth_state: &mut AuthState, auth_state: &mut AuthState,
login_state: &mut LoginState, login_state: &mut LoginFormState,
) -> bool { ) -> bool {
match result { match result {
LoginResult::Success(response) => { LoginResult::Success(response) => {
@@ -201,19 +182,15 @@ pub fn handle_login_result(
auth_state.role = Some(response.role.clone()); auth_state.role = Some(response.role.clone());
auth_state.decoded_username = Some(response.username.clone()); auth_state.decoded_username = Some(response.username.clone());
// --- NEW: Save auth data to file ---
let data_to_store = StoredAuthData { let data_to_store = StoredAuthData {
access_token: response.access_token.clone(), access_token: response.access_token.clone(),
user_id: response.user_id.clone(), user_id: response.user_id.clone(),
role: response.role.clone(), role: response.role.clone(),
username: response.username.clone(), username: response.username.clone(),
}; };
if let Err(e) = save_auth_data(&data_to_store) { if let Err(e) = save_auth_data(&data_to_store) {
error!("Failed to save auth data to file: {}", e); 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!( let success_message = format!(
"Login Successful!\n\nUsername: {}\nUser ID: {}\nRole: {}", "Login Successful!\n\nUsername: {}\nUser ID: {}\nRole: {}",
@@ -227,26 +204,28 @@ pub fn handle_login_result(
info!(message = %success_message, "Login successful"); info!(message = %success_message, "Login successful");
} }
LoginResult::Failure(err_msg) | LoginResult::ConnectionError(err_msg) => { LoginResult::Failure(err_msg) | LoginResult::ConnectionError(err_msg) => {
app_state.update_dialog_content(&err_msg, vec!["OK".to_string()], DialogPurpose::LoginFailed); app_state.update_dialog_content(
login_state.error_message = Some(err_msg.clone()); &err_msg,
vec!["OK".to_string()],
DialogPurpose::LoginFailed,
);
login_state.set_error_message(Some(err_msg.clone()));
error!(error = %err_msg, "Login failed/connection error"); error!(error = %err_msg, "Login failed/connection error");
} }
} }
login_state.username.clear();
login_state.password.clear(); login_state.username_mut().clear();
login_state.password_mut().clear();
login_state.set_has_unsaved_changes(false); login_state.set_has_unsaved_changes(false);
login_state.current_cursor_pos = 0; login_state.set_current_cursor_pos(0);
true // Request redraw as dialog content changed
true
} }
pub async fn handle_action(action: &str,) -> Result<String> { pub async fn handle_action(action: &str) -> Result<String> {
match action { match action {
"previous_entry" => { "previous_entry" => Ok("Previous entry not implemented".into()),
Ok("Previous entry at tui/functions/login.rs not implemented".into()) "next_entry" => Ok("Next entry not implemented".into()),
} _ => Err(anyhow!("Unknown login action: {}", action)),
"next_entry" => {
Ok("Next entry at tui/functions/login.rs not implemented".into())
}
_ => Err(anyhow!("Unknown login action: {}", action))
} }
} }

View File

@@ -1,6 +1,8 @@
// src/pages/login/state.rs // src/pages/login/state.rs
use canvas::{AppMode, DataProvider}; use canvas::{AppMode, DataProvider};
use canvas::FormEditor;
use std::fmt;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct LoginState { pub struct LoginState {
@@ -119,3 +121,114 @@ impl DataProvider for LoginState {
false // Login form doesn't support suggestions false // Login form doesn't support suggestions
} }
} }
/// Wrapper that owns both the raw login state and its editor
pub struct LoginFormState {
pub state: LoginState,
pub editor: FormEditor<LoginState>,
}
// manual debug because FormEditor doesnt implement debug
impl fmt::Debug for LoginFormState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("LoginFormState")
.field("state", &self.state) // ✅ only print the data
.finish()
}
}
impl LoginFormState {
/// Create a new LoginFormState with default LoginState and FormEditor
pub fn new() -> Self {
let state = LoginState::default();
let editor = FormEditor::new(state.clone());
Self { state, editor }
}
// === Delegates to LoginState fields ===
pub fn username(&self) -> &str {
&self.state.username
}
pub fn username_mut(&mut self) -> &mut String {
&mut self.state.username
}
pub fn password(&self) -> &str {
&self.state.password
}
pub fn password_mut(&mut self) -> &mut String {
&mut self.state.password
}
pub fn error_message(&self) -> Option<&String> {
self.state.error_message.as_ref()
}
pub fn set_error_message(&mut self, msg: Option<String>) {
self.state.error_message = msg;
}
pub fn has_unsaved_changes(&self) -> bool {
self.state.has_unsaved_changes
}
pub fn set_has_unsaved_changes(&mut self, changed: bool) {
self.state.has_unsaved_changes = changed;
}
pub fn clear(&mut self) {
self.state.username.clear();
self.state.password.clear();
self.state.error_message = None;
self.state.has_unsaved_changes = false;
self.state.login_request_pending = false;
self.state.current_cursor_pos = 0;
}
// === Delegates to LoginState cursor/input ===
pub fn current_field(&self) -> usize {
self.state.current_field()
}
pub fn set_current_field(&mut self, index: usize) {
self.state.set_current_field(index);
}
pub fn current_cursor_pos(&self) -> usize {
self.state.current_cursor_pos()
}
pub fn set_current_cursor_pos(&mut self, pos: usize) {
self.state.set_current_cursor_pos(pos);
}
pub fn get_current_input(&self) -> &str {
self.state.get_current_input()
}
pub fn get_current_input_mut(&mut self) -> &mut String {
self.state.get_current_input_mut()
}
// === Delegates to FormEditor ===
pub fn mode(&self) -> AppMode {
self.editor.mode()
}
pub fn cursor_position(&self) -> usize {
self.editor.cursor_position()
}
pub fn handle_key_event(
&mut self,
key_event: crossterm::event::KeyEvent,
) -> canvas::keymap::KeyEventOutcome {
self.editor.handle_key_event(key_event)
}
}

View File

@@ -16,17 +16,20 @@ use canvas::{
render_suggestions_dropdown, render_suggestions_dropdown,
DefaultCanvasTheme, DefaultCanvasTheme,
}; };
use crate::pages::login::LoginState;
use crate::pages::login::LoginFormState;
use crate::dialog; use crate::dialog;
pub fn render_login( pub fn render_login(
f: &mut Frame, f: &mut Frame,
area: Rect, area: Rect,
theme: &Theme, theme: &Theme,
// FIX: take &LoginState (reference), not owned login_page: &LoginFormState,
login_state: &LoginState,
app_state: &AppState, app_state: &AppState,
) { ) {
let login_state = &login_page.state;
let editor = &login_page.editor;
// Main container // Main container
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
@@ -52,14 +55,10 @@ pub fn render_login(
]) ])
.split(inner_area); .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( let input_rect = render_canvas(
f, f,
chunks[0], chunks[0],
&editor, editor,
&DefaultCanvasTheme, &DefaultCanvasTheme,
); );
@@ -134,14 +133,14 @@ pub fn render_login(
); );
// --- SUGGESTIONS DROPDOWN (if active) --- // --- SUGGESTIONS DROPDOWN (if active) ---
if editor.mode() == canvas::AppMode::Edit { if editor.mode() == canvas::AppMode::Edit {
if let Some(input_rect) = input_rect { if let Some(input_rect) = input_rect {
render_suggestions_dropdown( render_suggestions_dropdown(
f, f,
f.area(), chunks[0],
input_rect, input_rect,
&DefaultCanvasTheme, &DefaultCanvasTheme,
&editor, // FIX: pass &editor editor,
); );
} }
} }

View File

@@ -6,14 +6,14 @@ use crate::state::pages::{
}; };
use crate::pages::admin::AdminState; use crate::pages::admin::AdminState;
use crate::pages::forms::FormState; use crate::pages::forms::FormState;
use crate::pages::login::LoginState; use crate::pages::login::LoginFormState;
use crate::pages::register::RegisterState; use crate::pages::register::RegisterState;
use crate::pages::intro::IntroState; use crate::pages::intro::IntroState;
#[derive(Debug)] #[derive(Debug)]
pub enum Page { pub enum Page {
Intro(IntroState), Intro(IntroState),
Login(LoginState), Login(LoginFormState),
Register(RegisterState), Register(RegisterState),
Admin(AdminState), Admin(AdminState),
AddLogic(AddLogicState), AddLogic(AddLogicState),

View File

@@ -76,11 +76,11 @@ pub fn render_ui(
// Page rendering is now fully router-driven // Page rendering is now fully router-driven
match &mut router.current { match &mut router.current {
Page::Intro(state) => render_intro(f, state, main_content_area, theme), Page::Intro(state) => render_intro(f, state, main_content_area, theme),
Page::Login(state) => render_login( Page::Login(page) => render_login(
f, f,
main_content_area, main_content_area,
theme, theme,
state, page,
app_state, app_state,
), ),
Page::Register(state) => render_register( Page::Register(state) => render_register(

View File

@@ -10,6 +10,7 @@ use crate::modes::handlers::event::{EventHandler, EventOutcome};
use crate::modes::handlers::mode_manager::{AppMode, ModeManager}; use crate::modes::handlers::mode_manager::{AppMode, ModeManager};
use crate::state::pages::auth::AuthState; use crate::state::pages::auth::AuthState;
use crate::pages::register::RegisterState; use crate::pages::register::RegisterState;
use crate::pages::login::LoginFormState;
use crate::pages::admin::AdminState; use crate::pages::admin::AdminState;
use crate::pages::admin::AdminFocus; use crate::pages::admin::AdminFocus;
use crate::pages::intro::IntroState; use crate::pages::intro::IntroState;
@@ -28,6 +29,7 @@ use crate::pages::register::RegisterResult;
use crate::ui::handlers::context::DialogPurpose; use crate::ui::handlers::context::DialogPurpose;
use crate::utils::columns::filter_user_columns; use crate::utils::columns::filter_user_columns;
use canvas::keymap::KeyEventOutcome; use canvas::keymap::KeyEventOutcome;
use canvas::FormEditor;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use crossterm::cursor::{SetCursorStyle, MoveTo}; use crossterm::cursor::{SetCursorStyle, MoveTo};
use crossterm::event as crossterm_event; use crossterm::event as crossterm_event;
@@ -66,7 +68,7 @@ pub async fn run_ui() -> Result<()> {
let event_reader = EventReader::new(); let event_reader = EventReader::new();
let mut auth_state = AuthState::default(); let mut auth_state = AuthState::default();
let mut login_state = LoginState::default(); let mut login_state = LoginFormState::new();
let mut register_state = RegisterState::default(); let mut register_state = RegisterState::default();
let mut intro_state = IntroState::default(); let mut intro_state = IntroState::default();
let mut admin_state = AdminState::default(); let mut admin_state = AdminState::default();
@@ -350,7 +352,9 @@ pub async fn run_ui() -> Result<()> {
// Navigate with the up-to-date state // Navigate with the up-to-date state
router.navigate(Page::Intro(intro_state.clone())); router.navigate(Page::Intro(intro_state.clone()));
} }
AppView::Login => router.navigate(Page::Login(login_state.clone())), AppView::Login => {
router.navigate(Page::Login(LoginFormState::new()))
}
AppView::Register => router.navigate(Page::Register(register_state.clone())), AppView::Register => router.navigate(Page::Register(register_state.clone())),
AppView::Admin => { AppView::Admin => {
if let Page::Admin(current) = &router.current { if let Page::Admin(current) = &router.current {
@@ -619,8 +623,7 @@ pub async fn run_ui() -> Result<()> {
let current_input = state.get_current_input(); let current_input = state.get_current_input();
let max_cursor_pos = let max_cursor_pos =
if !current_input.is_empty() { current_input.len() - 1 } else { 0 }; if !current_input.is_empty() { current_input.len() - 1 } else { 0 };
state.current_cursor_pos = state.set_current_cursor_pos(event_handler.ideal_cursor_column.min(max_cursor_pos));
event_handler.ideal_cursor_column.min(max_cursor_pos);
} }
} }