phase 8 step 4: web-ui login endpoint (pipes, no pty)
This commit is contained in:
parent
78fae44ee5
commit
dff93b603d
4 changed files with 437 additions and 21 deletions
|
|
@ -249,9 +249,12 @@ See PLAN.md → "Phase 8" for the full design. Summary:
|
||||||
- **Login from the per-agent web UI.** Spawn `claude /login` with plain
|
- **Login from the per-agent web UI.** Spawn `claude /login` with plain
|
||||||
stdio pipes (no PTY initially), surface the OAuth URL from stdout on the
|
stdio pipes (no PTY initially), surface the OAuth URL from stdout on the
|
||||||
page, accept the resulting code via a paste field, write it to the process
|
page, accept the resulting code via a paste field, write it to the process
|
||||||
stdin. On success, harness transitions out of "needs login" and enters the
|
stdin. Once `~/.claude/` populates, the existing needs-login polling loop
|
||||||
turn loop. If pipes turn out to be insufficient (claude refuses without a
|
flips state to Online and starts the turn loop — no separate signaling
|
||||||
TTY, raw-mode input, ANSI-only output) we redo the backend with a PTY.
|
needed. The exact command is overridable via `HYPERHIVE_LOGIN_CMD` so we
|
||||||
|
can adjust without rebuilding. If pipes turn out to be insufficient
|
||||||
|
(claude refuses without a TTY, raw-mode input, ANSI-only output) we redo
|
||||||
|
the backend with a PTY (e.g. `portable-pty`).
|
||||||
|
|
||||||
Implementation order: bind-mount/dir creation → approval-gated spawn +
|
Implementation order: bind-mount/dir creation → approval-gated spawn +
|
||||||
spinner → "needs login" partial run → PTY login endpoint. The login UI has
|
spinner → "needs login" partial run → PTY login endpoint. The login UI has
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
|
pub mod login_session;
|
||||||
pub mod web_ui;
|
pub mod web_ui;
|
||||||
|
|
||||||
/// Default socket path inside the container — bind-mounted by `hive-c0re`.
|
/// Default socket path inside the container — bind-mounted by `hive-c0re`.
|
||||||
|
|
|
||||||
264
hive-ag3nt/src/login_session.rs
Normal file
264
hive-ag3nt/src/login_session.rs
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
//! `claude /login` driver. Spawns the login command under plain stdio pipes,
|
||||||
|
//! accumulates stdout+stderr in a shared buffer (so the web UI can show
|
||||||
|
//! whatever URL/prompt claude emits), and writes paste-back codes from the
|
||||||
|
//! UI into the child's stdin.
|
||||||
|
//!
|
||||||
|
//! No PTY — we're betting `claude` produces a parseable URL on stdout and
|
||||||
|
//! accepts a code on stdin even when not on a terminal. If it refuses or
|
||||||
|
//! garbles, we'll redo this module backed by `portable-pty` (see PLAN.md
|
||||||
|
//! Phase 8).
|
||||||
|
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::process::{Child, ChildStdin, Command};
|
||||||
|
|
||||||
|
const DEFAULT_CMD: &str = "claude";
|
||||||
|
const DEFAULT_ARGS: &[&str] = &["/login"];
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct State {
|
||||||
|
/// Concatenated stdout+stderr as it streams from the child.
|
||||||
|
output: String,
|
||||||
|
/// First URL-looking substring we saw in the output. Surface this on the
|
||||||
|
/// web UI as the link the operator should open.
|
||||||
|
url: Option<String>,
|
||||||
|
/// Set when the child has exited. The web UI uses this to know whether
|
||||||
|
/// the operator can still paste a code.
|
||||||
|
finished: bool,
|
||||||
|
/// Exit status note (e.g. "exited with code 0", "killed by signal 15"),
|
||||||
|
/// shown next to a "finished" badge once the child returns.
|
||||||
|
exit_note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A running `claude /login` subprocess.
|
||||||
|
pub struct LoginSession {
|
||||||
|
child: Mutex<Child>,
|
||||||
|
/// Tokio mutex because we hold the guard across the `write_all().await`
|
||||||
|
/// in `submit_code`. The other locks are blocking-only and stay on
|
||||||
|
/// `std::sync::Mutex`.
|
||||||
|
stdin: tokio::sync::Mutex<Option<ChildStdin>>,
|
||||||
|
state: Arc<Mutex<State>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoginSession {
|
||||||
|
/// Spawn the login command. The exact binary/args are configurable via
|
||||||
|
/// `HYPERHIVE_LOGIN_CMD` (single string, shell-split into argv); by
|
||||||
|
/// default we run `claude /login`. Failing to spawn returns an error
|
||||||
|
/// before any state is registered.
|
||||||
|
pub fn start() -> Result<Self> {
|
||||||
|
let (cmd, args) = resolve_command();
|
||||||
|
tracing::info!(%cmd, ?args, "spawning login session");
|
||||||
|
|
||||||
|
let mut child = Command::new(&cmd)
|
||||||
|
.args(&args)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
// `claude` reads $HOME for the credentials dir; the bind-mount
|
||||||
|
// puts it at /root/.claude, which is already the default home
|
||||||
|
// for uid 0 inside the container. Nothing extra to set here.
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.spawn()
|
||||||
|
.with_context(|| format!("spawn `{cmd}`"))?;
|
||||||
|
|
||||||
|
let stdin = child.stdin.take().context("child stdin")?;
|
||||||
|
let stdout = child.stdout.take().context("child stdout")?;
|
||||||
|
let stderr = child.stderr.take().context("child stderr")?;
|
||||||
|
|
||||||
|
let state = Arc::new(Mutex::new(State::default()));
|
||||||
|
tokio::spawn(pump(BufReader::new(stdout), state.clone(), "stdout"));
|
||||||
|
tokio::spawn(pump(BufReader::new(stderr), state.clone(), "stderr"));
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
child: Mutex::new(child),
|
||||||
|
stdin: tokio::sync::Mutex::new(Some(stdin)),
|
||||||
|
state,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `code` (plus a newline) to the child's stdin. Returns an error
|
||||||
|
/// if the stdin has already been closed (e.g. after the child exited or
|
||||||
|
/// after a prior submission consumed it).
|
||||||
|
pub async fn submit_code(&self, code: &str) -> Result<()> {
|
||||||
|
let mut guard = self.stdin.lock().await;
|
||||||
|
let stdin = guard.as_mut().context("login stdin already closed")?;
|
||||||
|
let line = format!("{}\n", code.trim());
|
||||||
|
stdin
|
||||||
|
.write_all(line.as_bytes())
|
||||||
|
.await
|
||||||
|
.context("write code to claude stdin")?;
|
||||||
|
stdin.flush().await.context("flush claude stdin")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close stdin so claude sees EOF (useful if it's waiting for more input
|
||||||
|
/// after the code submit).
|
||||||
|
pub async fn close_stdin(&self) {
|
||||||
|
let _ = self.stdin.lock().await.take();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn output(&self) -> String {
|
||||||
|
self.state.lock().unwrap().output.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn url(&self) -> Option<String> {
|
||||||
|
self.state.lock().unwrap().url.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finished(&self) -> bool {
|
||||||
|
self.state.lock().unwrap().finished
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn exit_note(&self) -> Option<String> {
|
||||||
|
self.state.lock().unwrap().exit_note.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort: poll the child once and update `finished`/`exit_note`.
|
||||||
|
/// Called by the web UI on each render so the state stays fresh without
|
||||||
|
/// running a dedicated reaper task.
|
||||||
|
pub fn poll(&self) {
|
||||||
|
let mut child = self.child.lock().unwrap();
|
||||||
|
match child.try_wait() {
|
||||||
|
Ok(Some(status)) => {
|
||||||
|
let mut s = self.state.lock().unwrap();
|
||||||
|
s.finished = true;
|
||||||
|
s.exit_note = Some(format!("{status}"));
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => {
|
||||||
|
let mut s = self.state.lock().unwrap();
|
||||||
|
s.finished = true;
|
||||||
|
s.exit_note = Some(format!("try_wait error: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kill the child if it's still running. Idempotent.
|
||||||
|
pub fn kill(&self) {
|
||||||
|
if let Err(e) = self.child.lock().unwrap().start_kill() {
|
||||||
|
tracing::warn!(error = ?e, "kill login child");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_command() -> (String, Vec<String>) {
|
||||||
|
if let Ok(raw) = std::env::var("HYPERHIVE_LOGIN_CMD") {
|
||||||
|
// Whitespace-only split — no quote handling. Fine for "claude /login"
|
||||||
|
// style overrides; if we need anything with embedded spaces we'll
|
||||||
|
// switch to shell-words.
|
||||||
|
let mut parts = raw.split_whitespace().map(str::to_owned);
|
||||||
|
if let Some(cmd) = parts.next() {
|
||||||
|
return (cmd, parts.collect());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(
|
||||||
|
DEFAULT_CMD.into(),
|
||||||
|
DEFAULT_ARGS.iter().map(|s| (*s).to_owned()).collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pump<R: tokio::io::AsyncRead + Unpin>(
|
||||||
|
mut reader: BufReader<R>,
|
||||||
|
state: Arc<Mutex<State>>,
|
||||||
|
tag: &'static str,
|
||||||
|
) {
|
||||||
|
let mut buf = String::new();
|
||||||
|
loop {
|
||||||
|
buf.clear();
|
||||||
|
// read_line breaks on \n; for claude's TUI output that flushes by
|
||||||
|
// line this is fine. If it ever blasts a single un-newlined blob,
|
||||||
|
// we'll miss it until EOF (acceptable for the URL surface — claude
|
||||||
|
// prints the URL on its own line).
|
||||||
|
match reader.read_line(&mut buf).await {
|
||||||
|
Ok(0) => {
|
||||||
|
state.lock().unwrap().finished = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
let mut s = state.lock().unwrap();
|
||||||
|
if s.url.is_none()
|
||||||
|
&& let Some(url) = extract_url(&buf)
|
||||||
|
{
|
||||||
|
tracing::info!(%url, %tag, "login URL detected");
|
||||||
|
s.url = Some(url);
|
||||||
|
}
|
||||||
|
s.output.push_str(&buf);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, %tag, "login pump read error");
|
||||||
|
let mut s = state.lock().unwrap();
|
||||||
|
s.finished = true;
|
||||||
|
s.exit_note = Some(format!("pump {tag} error: {e}"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the first `https://…` substring on the line, terminating at any
|
||||||
|
/// ASCII whitespace. Good enough for capturing claude's OAuth link without a
|
||||||
|
/// regex dependency.
|
||||||
|
fn extract_url(line: &str) -> Option<String> {
|
||||||
|
let start = line.find("https://")?;
|
||||||
|
let tail = &line[start..];
|
||||||
|
let end = tail
|
||||||
|
.find(|c: char| c.is_ascii_whitespace())
|
||||||
|
.unwrap_or(tail.len());
|
||||||
|
let url = tail[..end].trim_end_matches(['.', ',', ')', ']']);
|
||||||
|
if url.len() > "https://".len() {
|
||||||
|
Some(url.to_owned())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper used by the web UI to gate "is there a session running right now"
|
||||||
|
/// without holding both this module's mutex and the `AppState`'s at once.
|
||||||
|
pub fn drop_if_finished(slot: &Mutex<Option<Arc<LoginSession>>>) {
|
||||||
|
let mut guard = slot.lock().unwrap();
|
||||||
|
if let Some(s) = guard.as_ref() {
|
||||||
|
s.poll();
|
||||||
|
if s.finished() {
|
||||||
|
*guard = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for LoginSession {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// kill_on_drop on the Command also ensures the child dies, but we
|
||||||
|
// belt-and-brace it in case the runtime detaches.
|
||||||
|
let _ = self.child.lock().unwrap().start_kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::extract_url;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn picks_first_https() {
|
||||||
|
let line = " Go to https://claude.ai/oauth/abc?xyz=1 in your browser.\n";
|
||||||
|
assert_eq!(
|
||||||
|
extract_url(line).as_deref(),
|
||||||
|
Some("https://claude.ai/oauth/abc?xyz=1"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trailing_punctuation_stripped() {
|
||||||
|
let line = "Open https://example.com/abc).\n";
|
||||||
|
assert_eq!(
|
||||||
|
extract_url(line).as_deref(),
|
||||||
|
Some("https://example.com/abc"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_url() {
|
||||||
|
assert_eq!(extract_url("nothing here\n"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,9 +7,16 @@ use std::net::SocketAddr;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use axum::{Router, extract::State, response::Html, routing::get};
|
use axum::{
|
||||||
|
Form, Router,
|
||||||
|
extract::State,
|
||||||
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::login::LoginState;
|
use crate::login::LoginState;
|
||||||
|
use crate::login_session::{LoginSession, drop_if_finished};
|
||||||
|
|
||||||
/// Live login state for the web UI. The harness updates this in place as it
|
/// Live login state for the web UI. The harness updates this in place as it
|
||||||
/// transitions between `NeedsLogin` and `Online`; the UI reads on each
|
/// transitions between `NeedsLogin` and `Online`; the UI reads on each
|
||||||
|
|
@ -20,11 +27,21 @@ pub type LoginStateCell = Arc<Mutex<LoginState>>;
|
||||||
struct AppState {
|
struct AppState {
|
||||||
label: String,
|
label: String,
|
||||||
login: LoginStateCell,
|
login: LoginStateCell,
|
||||||
|
session: Arc<Mutex<Option<Arc<LoginSession>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn serve(label: String, port: u16, login: LoginStateCell) -> Result<()> {
|
pub async fn serve(label: String, port: u16, login: LoginStateCell) -> Result<()> {
|
||||||
let state = AppState { label, login };
|
let state = AppState {
|
||||||
let app = Router::new().route("/", get(index)).with_state(state);
|
label,
|
||||||
|
login,
|
||||||
|
session: Arc::new(Mutex::new(None)),
|
||||||
|
};
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/", get(index))
|
||||||
|
.route("/login/start", post(post_login_start))
|
||||||
|
.route("/login/code", post(post_login_code))
|
||||||
|
.route("/login/cancel", post(post_login_cancel))
|
||||||
|
.with_state(state);
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||||
let listener = tokio::net::TcpListener::bind(addr)
|
let listener = tokio::net::TcpListener::bind(addr)
|
||||||
.await
|
.await
|
||||||
|
|
@ -35,25 +52,121 @@ pub async fn serve(label: String, port: u16, login: LoginStateCell) -> Result<()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn index(State(state): State<AppState>) -> Html<String> {
|
async fn index(State(state): State<AppState>) -> Html<String> {
|
||||||
|
drop_if_finished(&state.session);
|
||||||
let login = *state.login.lock().unwrap();
|
let login = *state.login.lock().unwrap();
|
||||||
let (status_label, status_class, body_extra) = match login {
|
let session_snapshot = state.session.lock().unwrap().clone();
|
||||||
LoginState::Online => (
|
let body = match (login, session_snapshot) {
|
||||||
"▓█▓▒░ harness alive — turn loop running ▓█▓▒░",
|
(LoginState::Online, _) => render_online(),
|
||||||
"status-online",
|
(LoginState::NeedsLogin, None) => render_needs_login_idle(),
|
||||||
"<p class=\"meta\">phase 6a placeholder — turn-loop status / inbox / xterm.js coming in 6b+</p>",
|
(LoginState::NeedsLogin, Some(session)) => render_login_in_progress(&session),
|
||||||
),
|
|
||||||
LoginState::NeedsLogin => (
|
|
||||||
"▓█▓▒░ NEEDS L0G1N ▓█▓▒░",
|
|
||||||
"status-needs-login",
|
|
||||||
"<p>No Claude session in <code>~/.claude/</code>. The harness is up and reachable on this UI, but the turn loop is paused until you log in.</p>\n<p class=\"meta\">Phase 8 step 4 will wire a login form here that drives <code>claude /login</code> over plain stdio pipes. Until then: <code>nixos-container root-login</code> the container and run <code>claude</code> interactively, then restart the harness.</p>",
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
Html(format!(
|
Html(format!(
|
||||||
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta http-equiv=\"refresh\" content=\"5\">\n<title>{label} // hyperhive</title>\n{STYLE}\n</head>\n<body>\n<pre class=\"banner\">░▒▓█▓▒░ {label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░</pre>\n<h2>◆ {label} ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n<p class=\"{status_class}\">{status_label}</p>\n{body_extra}\n</body>\n</html>\n",
|
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta http-equiv=\"refresh\" content=\"3\">\n<title>{label} // hyperhive</title>\n{STYLE}\n</head>\n<body>\n<pre class=\"banner\">░▒▓█▓▒░ {label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░</pre>\n<h2>◆ {label} ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n{body}\n</body>\n</html>\n",
|
||||||
label = state.label,
|
label = state.label,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_online() -> String {
|
||||||
|
"<p class=\"status-online\">▓█▓▒░ harness alive — turn loop running ▓█▓▒░</p>\n<p class=\"meta\">phase 6a placeholder — turn-loop status / inbox / xterm.js coming in 6b+</p>".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_needs_login_idle() -> String {
|
||||||
|
"<p class=\"status-needs-login\">▓█▓▒░ NEEDS L0G1N ▓█▓▒░</p>\n<p>No Claude session in <code>~/.claude/</code>. The harness is up but the turn loop is paused until you log in.</p>\n<form method=\"POST\" action=\"/login/start\">\n <button type=\"submit\" class=\"btn btn-login\">◆ ST4RT L0G1N</button>\n</form>\n<p class=\"meta\">Spawns <code>claude /login</code> over plain stdio pipes. The OAuth URL will appear here when claude emits it; paste the resulting code back into the form below.</p>".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_login_in_progress(session: &Arc<LoginSession>) -> String {
|
||||||
|
let url_block = match session.url() {
|
||||||
|
Some(url) => format!(
|
||||||
|
"<p>▶ <a href=\"{url}\" target=\"_blank\" rel=\"noreferrer\">{url}</a></p>\n<p class=\"meta\">open this URL in a browser, complete the OAuth flow, paste the resulting code below.</p>",
|
||||||
|
url = html_escape(&url),
|
||||||
|
),
|
||||||
|
None => "<p class=\"meta\">waiting for claude to emit an OAuth URL on stdout… (output below)</p>".into(),
|
||||||
|
};
|
||||||
|
let exit_badge = if session.finished() {
|
||||||
|
let note = session.exit_note().unwrap_or_else(|| "exited".into());
|
||||||
|
format!(
|
||||||
|
"<p class=\"status-needs-login\">claude process exited: {note}. Start over if needed.</p>",
|
||||||
|
note = html_escape(¬e),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let output = session.output();
|
||||||
|
let code_form = if session.finished() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
"<form method=\"POST\" action=\"/login/code\" class=\"loginform\">\n <input name=\"code\" placeholder=\"paste OAuth code here\" required autocomplete=\"off\">\n <button type=\"submit\" class=\"btn btn-login\">◆ S3ND C0DE</button>\n</form>".into()
|
||||||
|
};
|
||||||
|
let cancel_form = "<form method=\"POST\" action=\"/login/cancel\" style=\"margin-top: 0.4em;\">\n <button type=\"submit\" class=\"btn btn-cancel\">cancel + kill</button>\n</form>".to_owned();
|
||||||
|
format!(
|
||||||
|
"<p class=\"status-needs-login\">▓█▓▒░ L0G1N 1N PR0GRESS ▓█▓▒░</p>\n{url_block}\n{code_form}\n{cancel_form}\n{exit_badge}\n<h3>output</h3>\n<pre class=\"diff\">{output}</pre>",
|
||||||
|
output = html_escape(&output),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Redirect::to("/").into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match LoginSession::start() {
|
||||||
|
Ok(session) => {
|
||||||
|
*state.session.lock().unwrap() = Some(Arc::new(session));
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => error_response(&format!("login start failed: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CodeForm {
|
||||||
|
code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
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("no login session running");
|
||||||
|
};
|
||||||
|
if let Err(e) = session.submit_code(&form.code).await {
|
||||||
|
return error_response(&format!("submit code failed: {e:#}"));
|
||||||
|
}
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_response(message: &str) -> Response {
|
||||||
|
(
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Html(format!(
|
||||||
|
"<!doctype html>\n<html><head>{STYLE}</head><body><h2>error</h2><pre class=\"diff\">{msg}</pre><p><a href=\"/\">← back</a></p></body></html>",
|
||||||
|
msg = html_escape(message),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn html_escape(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
}
|
||||||
|
|
||||||
const STYLE: &str = r#"
|
const STYLE: &str = r#"
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
|
|
@ -62,6 +175,8 @@ const STYLE: &str = r#"
|
||||||
--muted: #6c5c8c;
|
--muted: #6c5c8c;
|
||||||
--purple: #cc66ff;
|
--purple: #cc66ff;
|
||||||
--purple-dim: #4a1a6a;
|
--purple-dim: #4a1a6a;
|
||||||
|
--amber: #ffb84d;
|
||||||
|
--green: #66ff99;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
|
@ -80,7 +195,7 @@ const STYLE: &str = r#"
|
||||||
text-shadow: 0 0 6px rgba(204, 102, 255, 0.5);
|
text-shadow: 0 0 6px rgba(204, 102, 255, 0.5);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
h2 {
|
h2, h3 {
|
||||||
color: var(--purple);
|
color: var(--purple);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.15em;
|
letter-spacing: 0.15em;
|
||||||
|
|
@ -93,8 +208,41 @@ const STYLE: &str = r#"
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 0.5em;
|
||||||
}
|
}
|
||||||
.meta { color: var(--muted); font-size: 0.85em; }
|
.meta { color: var(--muted); font-size: 0.85em; }
|
||||||
.status-online { color: #66ff99; text-shadow: 0 0 6px rgba(102, 255, 153, 0.5); }
|
.status-online { color: var(--green); text-shadow: 0 0 6px rgba(102, 255, 153, 0.5); }
|
||||||
.status-needs-login { color: #ffb84d; text-shadow: 0 0 6px rgba(255, 184, 77, 0.6); }
|
.status-needs-login { color: var(--amber); text-shadow: 0 0 6px rgba(255, 184, 77, 0.6); }
|
||||||
code { background: rgba(204, 102, 255, 0.1); padding: 0.05em 0.3em; border-radius: 2px; }
|
code { background: rgba(204, 102, 255, 0.1); padding: 0.05em 0.3em; border-radius: 2px; }
|
||||||
|
a { color: #66e0ff; }
|
||||||
|
.btn {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1em;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--purple);
|
||||||
|
color: var(--purple);
|
||||||
|
padding: 0.25em 0.8em;
|
||||||
|
cursor: pointer;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
.btn:hover { background: rgba(204, 102, 255, 0.1); }
|
||||||
|
.btn-login { color: var(--amber); border-color: var(--amber); }
|
||||||
|
.btn-cancel { color: #ff6b6b; border-color: #ff6b6b; font-size: 0.85em; padding: 0.15em 0.6em; }
|
||||||
|
.loginform { display: flex; gap: 0.6em; margin-top: 0.5em; }
|
||||||
|
.loginform input {
|
||||||
|
font-family: inherit; font-size: 1em;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--fg);
|
||||||
|
border: 1px solid var(--purple-dim);
|
||||||
|
padding: 0.4em 0.6em;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.loginform input:focus { outline: 1px solid var(--purple); }
|
||||||
|
pre.diff {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--purple-dim);
|
||||||
|
padding: 0.6em 0.8em;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
max-height: 30em;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
"#;
|
"#;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue