//! Login / logout flow handlers (`/login/*`, `/api/logout`). use std::sync::Arc; use axum::{ Form, extract::State, http::StatusCode, response::{IntoResponse, Response}, }; use serde::Deserialize; use crate::login::LoginState; use crate::login_session::{LoginSession, drop_if_finished}; use super::{AppState, error_response}; pub(super) async fn post_login_start(State(state): State) -> Response { drop_if_finished(&state.session); { let guard = state.session.lock().unwrap(); if guard.is_some() { return (axum::http::StatusCode::OK, "ok").into_response(); } } match LoginSession::start() { Ok(session) => { *state.session.lock().unwrap() = Some(Arc::new(session)); // Flip status from needs_login_idle → needs_login_in_progress // so the web UI's badge + polling kick in (polling is still // the right tool for the streaming session output during // the login flow itself; events drop the poll for // *everything else*). state.bus.emit_status("needs_login_in_progress"); (axum::http::StatusCode::OK, "ok").into_response() } Err(e) => error_response( StatusCode::INTERNAL_SERVER_ERROR, &format!("login start failed: {e:#}"), ), } } #[derive(Deserialize)] pub(super) struct CodeForm { code: String, } pub(super) async fn post_login_code( State(state): State, Form(form): Form, ) -> Response { let session = state.session.lock().unwrap().clone(); let Some(session) = session else { return error_response(StatusCode::CONFLICT, "no login session running"); }; if let Err(e) = session.submit_code(&form.code).await { return error_response( StatusCode::INTERNAL_SERVER_ERROR, &format!("submit code failed: {e:#}"), ); } (axum::http::StatusCode::OK, "ok").into_response() } pub(super) async fn post_login_cancel(State(state): State) -> Response { let session = state.session.lock().unwrap().take(); if let Some(session) = session { session.close_stdin().await; session.kill(); } // Back to needs_login_idle (LoginState unchanged, session gone). state.bus.emit_status("needs_login_idle"); (axum::http::StatusCode::OK, "ok").into_response() } /// Operator-driven `/logout`: SIGINT claude, delete the credential /// files (via [`crate::login::clear_session`]), flip `LoginState::NeedsLogin`. /// The turn loop's next iteration parks into `wait_for_login` which /// resumes when a fresh credentials file appears via `/login/code`. /// Always returns 200 with a body describing what happened. See /// [`docs/web-ui/agent.md::Per-agent endpoints`](../../../docs/web-ui/agent.md) /// (the `/api/logout` bullet) for the three-step rationale + /// preservation invariants. pub(super) async fn post_logout(State(state): State) -> Response { // Step 1: SIGINT claude (best-effort, matches `post_cancel_turn`). let _ = super::sigint_claude().await; // Step 2: delete OAuth credential files only — login::clear_session owns // the file set and preserves session-history files alongside them. let dir = crate::paths::claude_dir(); let cleared = crate::login::clear_session(&dir).await; let wipe_summary = if cleared.wiped.is_empty() { "no credential files present (already logged out)".to_owned() } else { format!("wiped {}", cleared.wiped.join(", ")) }; let warn_suffix = if cleared.warnings.is_empty() { String::new() } else { format!(" (warnings: {})", cleared.warnings.join("; ")) }; // Step 3: flip LoginState + emit Note. Turn loop sees the flip on // its next iteration and parks into wait_for_login. *state.login.lock().unwrap() = LoginState::NeedsLogin; state.bus.emit(crate::events::LiveEvent::Note { text: format!( "operator: /logout — {wipe_summary} in {}{warn_suffix}", dir.display() ), }); state.bus.emit_status("needs_login_idle"); ( axum::http::StatusCode::OK, format!("ok: {wipe_summary} in {}{warn_suffix}", dir.display()), ) .into_response() }