Files
furumi_tui/src/app/login.rs
T
Ultradesu 39b955b6e7 Init
2026-06-10 16:11:09 +01:00

191 lines
6.4 KiB
Rust

use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::api::{auth, client};
use crate::app::Runtime;
use crate::app::event::AppEvent;
use crate::app::sso;
use crate::app::state::{AppState, LoginField, LoginForm, LoginMode};
pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
state.should_quit = true;
return;
}
let form = &mut state.login;
if form.busy {
return;
}
match form.mode {
LoginMode::Form => handle_form_key(form, runtime, key),
LoginMode::SsoPending => handle_sso_key(form, runtime, key),
}
}
/// Bracketed paste goes into whichever text field is focused.
pub fn handle_paste(state: &mut AppState, pasted: &str) {
let form = &mut state.login;
if form.busy {
return;
}
let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect();
if let Some(field) = focused_text(form) {
field.push_str(&cleaned);
}
}
fn handle_form_key(form: &mut LoginForm, runtime: &mut Runtime, key: KeyEvent) {
match key.code {
KeyCode::Tab | KeyCode::Down => form.focus = form.focus.next(),
KeyCode::BackTab | KeyCode::Up => form.focus = form.focus.prev(),
KeyCode::Backspace => {
if let Some(field) = focused_text(form) {
field.pop();
}
}
KeyCode::Enter => match form.focus {
LoginField::ServerUrl | LoginField::Username => form.focus = form.focus.next(),
LoginField::Password | LoginField::SignInButton => {
submit_password(form, runtime);
}
LoginField::SsoButton => start_sso(form, runtime),
},
KeyCode::Char(c) if is_typing(key) => {
if let Some(field) = focused_text(form) {
field.push(c);
}
}
_ => {}
}
}
fn handle_sso_key(form: &mut LoginForm, runtime: &mut Runtime, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
if let Some(listener) = runtime.sso.take() {
listener.abort();
}
form.mode = LoginMode::Form;
form.sso_paste.clear();
form.error = None;
}
KeyCode::Backspace => {
form.sso_paste.pop();
}
KeyCode::Enter => submit_sso_code(form, runtime),
KeyCode::Char(c) if is_typing(key) => form.sso_paste.push(c),
_ => {}
}
}
fn is_typing(key: KeyEvent) -> bool {
key.modifiers
.difference(KeyModifiers::SHIFT)
.is_empty()
}
fn focused_text(form: &mut LoginForm) -> Option<&mut String> {
if form.mode == LoginMode::SsoPending {
return Some(&mut form.sso_paste);
}
match form.focus {
LoginField::ServerUrl => Some(&mut form.server_url),
LoginField::Username => Some(&mut form.username),
LoginField::Password => Some(&mut form.password),
LoginField::SignInButton | LoginField::SsoButton => None,
}
}
fn submit_password(form: &mut LoginForm, runtime: &Runtime) {
form.error = None;
let base_url = match auth::normalize_base_url(&form.server_url) {
Ok(url) => url,
Err(err) => return form.error = Some(err.to_string()),
};
let username = form.username.trim().to_string();
if username.is_empty() {
return form.error = Some("enter a username".to_string());
}
if form.password.is_empty() {
return form.error = Some("enter a password".to_string());
}
form.server_url = base_url.clone();
form.busy = true;
let password = form.password.clone();
let http = runtime.http.clone();
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let result = client::login_password(&http, &base_url, &username, &password).await;
let _ = tx.send(login_event(result));
});
}
fn start_sso(form: &mut LoginForm, runtime: &mut Runtime) {
form.error = None;
let base_url = match auth::normalize_base_url(&form.server_url) {
Ok(url) => url,
Err(err) => return form.error = Some(err.to_string()),
};
form.server_url = base_url.clone();
form.sso_paste.clear();
// Preferred flow: loopback listener, the browser redirect finishes the
// login hands-free. Fallback: furumi:// deep link + manual code paste.
if let Some(listener) = runtime.sso.take() {
listener.abort();
}
match sso::start(runtime.event_tx.clone()) {
Ok(listener) => {
let redirect = format!("http://127.0.0.1:{}/callback", listener.port);
form.sso_url = client::sso_start_url(&base_url, &redirect);
form.sso_port = Some(listener.port);
runtime.sso = Some(listener);
}
Err(err) => {
tracing::warn!(%err, "loopback listener unavailable, falling back to manual paste");
form.sso_url = client::sso_start_url(&base_url, "furumi://auth/callback");
form.sso_port = None;
}
}
form.mode = LoginMode::SsoPending;
if let Err(err) = open::that_detached(&form.sso_url) {
tracing::warn!(%err, "failed to open browser for SSO");
form.error = Some("couldn't open a browser — use the URL below".to_string());
}
}
fn submit_sso_code(form: &mut LoginForm, runtime: &Runtime) {
form.error = None;
let code = match auth::extract_sso_code(&form.sso_paste) {
Ok(code) => code,
Err(err) => return form.error = Some(err.to_string()),
};
spawn_sso_exchange(form, runtime, code);
}
/// Used by both the manual paste path and the loopback callback event.
pub fn spawn_sso_exchange(form: &mut LoginForm, runtime: &Runtime, code: String) {
let base_url = form.server_url.clone();
form.busy = true;
let http = runtime.http.clone();
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let result = client::login_sso_exchange(&http, &base_url, &code).await;
let _ = tx.send(login_event(result));
});
}
fn login_event(result: Result<auth::AuthSession, client::ApiError>) -> AppEvent {
match result {
Ok(session) => {
if let Err(err) = auth::save_session(&session) {
tracing::warn!(%err, "failed to persist credentials");
}
AppEvent::LoginSucceeded(Box::new(session))
}
Err(err) => AppEvent::LoginFailed(err.to_string()),
}
}