refactor(web_ui): split into a module dir by concern, serve stays in mod.rs
This commit is contained in:
parent
efaf56c2d5
commit
bdfeac80a7
8 changed files with 1311 additions and 1232 deletions
141
hive-ag3nt/src/web_ui/auth.rs
Normal file
141
hive-ag3nt/src/web_ui/auth.rs
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
//! 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()
|
||||
}
|
||||
|
||||
/// OAuth credential filenames inside `paths::claude_dir()`. Wiping
|
||||
/// only these (and not the rest of `~/.claude/`) preserves session
|
||||
/// history so `claude --continue` keeps working after a fresh login.
|
||||
/// Rationale + the previous wholesale-wipe shape we replaced live in
|
||||
/// [`docs/web-ui/agent.md::Per-agent endpoints`](../../../docs/web-ui/agent.md)
|
||||
/// (the `/api/logout` bullet).
|
||||
const CRED_FILE_NAMES: &[&str] = &[".credentials.json", "mcp-needs-auth-cache.json"];
|
||||
|
||||
/// Operator-driven `/logout`: SIGINT claude, delete the credential
|
||||
/// files in `CRED_FILE_NAMES`, 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 _ = tokio::process::Command::new("pkill")
|
||||
.args(["-INT", "claude"])
|
||||
.output()
|
||||
.await;
|
||||
// Step 2: delete OAuth credential files only — preserve session
|
||||
// history files alongside them.
|
||||
let dir = crate::paths::claude_dir();
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
let mut wiped: Vec<&str> = Vec::new();
|
||||
for name in CRED_FILE_NAMES {
|
||||
let path = dir.join(name);
|
||||
match tokio::fs::remove_file(&path).await {
|
||||
Ok(()) => wiped.push(name),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
// Already gone — operator clicked /logout while
|
||||
// already logged out, or the file simply didn't exist
|
||||
// for this agent. Idempotent.
|
||||
}
|
||||
Err(e) => warnings.push(format!("{name}: {e}")),
|
||||
}
|
||||
}
|
||||
let wipe_summary = if wiped.is_empty() {
|
||||
"no credential files present (already logged out)".to_owned()
|
||||
} else {
|
||||
format!("wiped {}", wiped.join(", "))
|
||||
};
|
||||
let warn_suffix = if warnings.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" (warnings: {})", 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()
|
||||
}
|
||||
Loading…
Reference in a new issue