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:
iris 2026-08-28 17:11:20 +02:00
commit ae94fff5af
4 changed files with 209 additions and 3 deletions

View 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>
);
}