Closes #2831. post_logout's wipe_summary only checked whether cleared.wiped was empty, so a real .credentials.json blocked by EPERM (nothing made it into wiped) got reported as 'no credential files present (already logged out)' in the same breath as a warning saying the delete failed -- self contradictory and misleading, since the file is still there un-deleted. Extracted the branch into a pure wipe_summary() helper (wiped file(s) named > warnings-only failure > genuinely nothing found) with unit tests covering the exact permission-error shape from the issue.
178 lines
6.5 KiB
Rust
178 lines
6.5 KiB
Rust
//! 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<AppState>) -> 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<AppState>,
|
|
Form(form): Form<CodeForm>,
|
|
) -> 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<AppState>) -> 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<AppState>) -> 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 = wipe_summary(&cleared);
|
|
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()
|
|
}
|
|
|
|
/// Human-readable summary of a [`crate::login::ClearedSession`] outcome for
|
|
/// the `/api/logout` response + note. Deliberately distinguishes "nothing to
|
|
/// delete" from "something's there but we couldn't delete it" — both leave
|
|
/// `wiped` empty, but only the former is actually "already logged out".
|
|
/// These two used to be conflated, so a stuck undeletable `.credentials.json`
|
|
/// got reported as "already logged out" in the same breath as a warning
|
|
/// saying the delete failed.
|
|
fn wipe_summary(cleared: &crate::login::ClearedSession) -> String {
|
|
if !cleared.wiped.is_empty() {
|
|
format!("wiped {}", cleared.wiped.join(", "))
|
|
} else if cleared.warnings.is_empty() {
|
|
"no credential files present (already logged out)".to_owned()
|
|
} else {
|
|
"failed to delete existing credential file(s), NOT logged out".to_owned()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::wipe_summary;
|
|
use crate::login::ClearedSession;
|
|
|
|
#[test]
|
|
fn nothing_found_reports_already_logged_out() {
|
|
let cleared = ClearedSession::default();
|
|
assert_eq!(
|
|
wipe_summary(&cleared),
|
|
"no credential files present (already logged out)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wiped_files_are_named() {
|
|
let cleared = ClearedSession {
|
|
wiped: vec![".credentials.json"],
|
|
warnings: vec![],
|
|
};
|
|
assert_eq!(wipe_summary(&cleared), "wiped .credentials.json");
|
|
}
|
|
|
|
#[test]
|
|
fn permission_error_does_not_claim_already_logged_out() {
|
|
// A real file blocked by EPERM must not be reported the same way
|
|
// as a genuinely absent one.
|
|
let cleared = ClearedSession {
|
|
wiped: vec![],
|
|
warnings: vec![".credentials.json: Permission denied (os error 13)".to_owned()],
|
|
};
|
|
let summary = wipe_summary(&cleared);
|
|
assert!(!summary.contains("already logged out"));
|
|
assert!(summary.contains("failed to delete"));
|
|
}
|
|
|
|
#[test]
|
|
fn partial_wipe_with_warnings_still_reports_wiped_files() {
|
|
// A mix of successes and failures should favor telling the operator
|
|
// what actually happened over the negative "nothing" framing.
|
|
let cleared = ClearedSession {
|
|
wiped: vec![".credentials.json"],
|
|
warnings: vec!["settings.json: Permission denied (os error 13)".to_owned()],
|
|
};
|
|
assert_eq!(wipe_summary(&cleared), "wiped .credentials.json");
|
|
}
|
|
}
|