agent: LoginFlow — the needs_login recovery UI app.js's rewrite was missing
Ports app.js's renderNeedsLoginIdle/renderLoginInProgress as real
functional Preact UI, not just a StatusChip label: a start-login
button (POST login/start), the OAuth-URL link once claude emits it,
a code-paste form (POST login/code, masked + reveal toggle) with
cancel (POST login/cancel), and the streamed process output. Without
this, an agent needing re-login (post-/logout, credential rotation)
had no web-UI path back in through the new page — the gap I flagged
before the cutover; mara: "1 - do it now".
Reuses agent.css's existing .agent-status-overlay/.status-needs-login/
.btn-login/.loginform*/.diff rules verbatim (same class names as the
old markup), matching Header.tsx's precedent — no new CSS file needed.
Added SessionView to types.ts (mirrors hive-agent's SessionView struct
exactly: url/output/finished/exit_note) and lib/loginAction.ts (same
{ok,detail} fetch shape as modelEffort.ts — same-origin POSTs, no CORS
concerns like pauseAction.ts's dashboard-origin actions).
Screenshot-verified all 3 states (idle, in-progress-with-url, finished
with error) against a mock server.
This commit is contained in:
parent
48a1745bb5
commit
ae94fff5af
4 changed files with 209 additions and 3 deletions
|
|
@ -1,7 +1,9 @@
|
|||
// <Root> — root of the Preact rewrite. Wires `Header` + `StatusChips`
|
||||
// to the real `/api/state` snapshot via `useAgentState`. Still a slice,
|
||||
// not the whole page — see main.tsx's file comment for what's left
|
||||
// (the live SSE stream, login flow, inbox/todos, term input, overflow).
|
||||
// to the real `/api/state` snapshot via `useAgentState`. Covers
|
||||
// everything `app.js` did, including the `needs_login_idle`/
|
||||
// `needs_login_in_progress` recovery flow (`LoginFlow`) — an agent with
|
||||
// no credentials has no other web-UI path back online, so this isn't
|
||||
// optional polish.
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import type { BadgeTone } from '@hive/shared/badge.js';
|
||||
import { Header } from './components/Header.js';
|
||||
|
|
@ -13,6 +15,7 @@ import { InboxPanel } from './components/InboxPanel.js';
|
|||
import { TodosPanel } from './components/TodosPanel.js';
|
||||
import { TermInput } from './components/TermInput.js';
|
||||
import { OverflowMenu } from './components/OverflowMenu.js';
|
||||
import { LoginFlow } from './components/LoginFlow.js';
|
||||
import { useAgentState } from './hooks/useAgentState.js';
|
||||
import { useTodos } from './hooks/useTodos.js';
|
||||
import { fmtAge, fmtTokens } from './lib/format.js';
|
||||
|
|
@ -163,6 +166,9 @@ export function Root() {
|
|||
/>
|
||||
</Header>
|
||||
<main className="agent-main">
|
||||
{state.status === 'needs_login_idle' || state.status === 'needs_login_in_progress' ? (
|
||||
<LoginFlow status={state.status} session={state.session} onRefresh={refresh} />
|
||||
) : null}
|
||||
<LiveStream ref={liveStreamRef} onLiveTurnBoundary={refresh} />
|
||||
</main>
|
||||
{termInput}
|
||||
|
|
|
|||
161
frontend/packages/agent/src/components/LoginFlow.tsx
Normal file
161
frontend/packages/agent/src/components/LoginFlow.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
// <LoginFlow> — the `needs_login_idle` / `needs_login_in_progress`
|
||||
// overlay. Ports app.js's `renderNeedsLoginIdle`/`renderLoginInProgress`
|
||||
// as real functional UI, not just a status label: an agent whose
|
||||
// credentials got rotated (or hit `/logout`) has NO other way back
|
||||
// online, so this is load-bearing, not cosmetic. Reuses agent.css's
|
||||
// existing `.agent-status-overlay`/`.status-needs-login`/`.btn-login`/
|
||||
// `.loginform`/`.loginform-reveal` rules verbatim (same class names as
|
||||
// the old markup) — no component-local CSS needed, matching Header.tsx's
|
||||
// same reuse pattern.
|
||||
import { useState } from 'preact/hooks';
|
||||
import type { SessionView } from '../types.js';
|
||||
import { postLoginStart, postLoginCode, postLoginCancel } from '../lib/loginAction.js';
|
||||
|
||||
function LoginIdle({ onStarted }: { onStarted: () => void }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function submit(e: Event) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
postLoginStart().then((r) => {
|
||||
setBusy(false);
|
||||
if (r.ok) onStarted();
|
||||
else setError(r.detail ?? 'failed');
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p class="status-needs-login">◌ NEEDS L0G1N</p>
|
||||
<p>
|
||||
No Claude session in <code>~/.claude/</code>. The harness is up but the turn loop is
|
||||
paused until you log in.
|
||||
</p>
|
||||
<form onSubmit={submit}>
|
||||
<button type="submit" class="btn btn-login" disabled={busy}>
|
||||
◆ ST4RT L0G1N
|
||||
</button>
|
||||
</form>
|
||||
{error ? <p class="meta">error: {error}</p> : null}
|
||||
<p class="meta">
|
||||
Spawns <code>claude auth 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginInProgress({ session, onDone }: { session: SessionView | null; onDone: () => void }) {
|
||||
const [code, setCode] = useState('');
|
||||
const [reveal, setReveal] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const finished = session?.finished ?? false;
|
||||
|
||||
function submitCode(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!code) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
// Success: the next `/api/state` poll flips `status` back to
|
||||
// `online` (or leaves it `needs_login_in_progress` on a bad code,
|
||||
// with the process's own stderr showing up in `output` below) —
|
||||
// nothing local to update here either way.
|
||||
postLoginCode(code).then((r) => {
|
||||
setBusy(false);
|
||||
if (!r.ok) setError(r.detail ?? 'failed');
|
||||
});
|
||||
}
|
||||
|
||||
function cancel(e: Event) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
postLoginCancel().then(() => {
|
||||
setBusy(false);
|
||||
onDone();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p class="status-needs-login">◌ L0G1N 1N PR0GRESS</p>
|
||||
{session?.url ? (
|
||||
<>
|
||||
<p>
|
||||
▶{' '}
|
||||
<a href={session.url} target="_blank" rel="noreferrer">
|
||||
{session.url}
|
||||
</a>
|
||||
</p>
|
||||
<p class="meta">
|
||||
open this URL in a browser, complete the OAuth flow, paste the resulting code below.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p class="meta">waiting for claude to emit an OAuth URL on stdout… (output below)</p>
|
||||
)}
|
||||
{!finished ? (
|
||||
<form class="loginform" onSubmit={submitCode}>
|
||||
<input
|
||||
name="code"
|
||||
type={reveal ? 'text' : 'password'}
|
||||
placeholder="paste OAuth code here (hidden)"
|
||||
required
|
||||
autocomplete="one-time-code"
|
||||
spellcheck={false}
|
||||
value={code}
|
||||
onInput={(e) => setCode((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="loginform-reveal"
|
||||
title="show / hide pasted code"
|
||||
aria-label="show / hide pasted OAuth code"
|
||||
aria-pressed={reveal}
|
||||
onClick={() => setReveal((r) => !r)}
|
||||
>
|
||||
👁
|
||||
</button>
|
||||
<button type="submit" class="btn btn-login" disabled={busy}>
|
||||
◆ S3ND C0DE
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
<form style={{ marginTop: '0.4em' }} onSubmit={cancel}>
|
||||
<button type="submit" class="btn btn-cancel" disabled={busy}>
|
||||
cancel + kill
|
||||
</button>
|
||||
</form>
|
||||
{finished ? (
|
||||
<p class="status-needs-login">
|
||||
claude process exited: {session?.exit_note || 'exited'}. Start over if needed.
|
||||
</p>
|
||||
) : null}
|
||||
{error ? <p class="meta">error: {error}</p> : null}
|
||||
<h3>output</h3>
|
||||
<pre class="diff">{session?.output || ''}</pre>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginFlow({
|
||||
status,
|
||||
session,
|
||||
onRefresh,
|
||||
}: {
|
||||
status: 'needs_login_idle' | 'needs_login_in_progress';
|
||||
session: SessionView | null;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div class="agent-status-overlay">
|
||||
{status === 'needs_login_idle' ? (
|
||||
<LoginIdle onStarted={onRefresh} />
|
||||
) : (
|
||||
<LoginInProgress session={session} onDone={onRefresh} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
frontend/packages/agent/src/lib/loginAction.ts
Normal file
28
frontend/packages/agent/src/lib/loginAction.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// POST `/login/start` / `/login/code` / `/login/cancel` — same-origin
|
||||
// (this agent's own backend, `hive-agent/src/web_ui/auth.rs`), so a
|
||||
// plain `fetch` is safe (unlike `pauseAction.ts`'s dashboard-origin
|
||||
// actions). Same `{ ok, detail }` shape and `redirect: 'manual'` /
|
||||
// broad-2xx-4xx-treated-as-ok contract as `modelEffort.ts` — none of
|
||||
// these three routes actually redirect today, but the same tolerant
|
||||
// check costs nothing and keeps every same-origin POST in this package
|
||||
// behaving identically.
|
||||
async function post(path: string, body?: URLSearchParams): Promise<{ ok: boolean; detail?: string }> {
|
||||
try {
|
||||
const resp = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : undefined,
|
||||
body,
|
||||
redirect: 'manual',
|
||||
});
|
||||
const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400);
|
||||
if (ok) return { ok: true };
|
||||
const detail = await resp.text().catch(() => '');
|
||||
return { ok: false, detail: `http ${resp.status}${detail ? ' — ' + detail : ''}` };
|
||||
} catch (err) {
|
||||
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export const postLoginStart = () => post('login/start');
|
||||
export const postLoginCode = (code: string) => post('login/code', new URLSearchParams({ code }));
|
||||
export const postLoginCancel = () => post('login/cancel');
|
||||
|
|
@ -10,6 +10,7 @@ export interface AgentState {
|
|||
qualified_label: string;
|
||||
dashboard_port: number;
|
||||
status: 'online' | 'rate_limited' | 'needs_login_idle' | 'needs_login_in_progress';
|
||||
session: SessionView | null;
|
||||
turn_state: string;
|
||||
turn_state_since: number;
|
||||
model: string;
|
||||
|
|
@ -25,6 +26,16 @@ export interface AgentState {
|
|||
inbox: InboxRow[];
|
||||
}
|
||||
|
||||
// Mirrors `hive_agent::web_ui::state::SessionView` — populated only
|
||||
// while `status === 'needs_login_in_progress'` (the in-flight
|
||||
// `claude auth login` session's streamed output).
|
||||
export interface SessionView {
|
||||
url: string | null;
|
||||
output: string;
|
||||
finished: boolean;
|
||||
exit_note: string | null;
|
||||
}
|
||||
|
||||
export interface TokenUsage {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
|
|
|
|||
Loading…
Reference in a new issue