191 lines
6.4 KiB
Rust
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()),
|
|
}
|
|
}
|