Files
proxy-manager/web/ConnectionPanel.tsx
T
pastilhasandClaude Opus 5 66383aa9a4 the lists re-read when the agent changes something
an agent deleted two proxy hosts and both stayed on screen. the person had the
answer in the transcript and a stale list six inches above it — which is the
one thing a chat beside a list has to get right, and the reason it is beside
the list at all.

so the chat panel bumps the refresh channel when a turn ran tools, and every
section re-reads. gated on hadToolCalls: a turn that only talked changed
nothing, and re-reading after every sentence would put six requests behind
every reply.

the channel is renamed with it. it was proxy-manager:connection-changed, named
after its only publisher; it has two now and the second is the bigger one, so
it says what it MEANS — something at NPM changed, read it again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 19:03:35 +00:00

351 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from 'react';
import { CheckCircle2, Loader2, Plug, ShieldAlert, Trash2, TriangleAlert } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { readableError } from './errors';
import { useNpmChanged } from './channel';
import { useSearchParams } from 'react-router';
/**
* The connection to Nginx Proxy Manager — both the setup wizard and the permanent settings page.
*
* One component for both, the same choice Jellyfin's `ConnectionSection` makes and for the same reason: a
* connection edited months later goes through exactly the code path that stored the first one. `HostsPanel`
* renders this in place of itself while nothing is configured, and `?config=1` renders it for good.
*
* ── Owner only, and it says so rather than failing oddly ──
*
* The manifest marks `/_config` `ownerOnly`, so every request from this component 403s for anybody else —
* including the test button, deliberately, because that request carries the admin secret in its body. A
* member reaching this screen should read a sentence explaining that, not a raw refusal.
*
* ── The secret is never loaded ──
*
* `GET /_config` returns `hasSecret`, never the secret. So the field starts empty even when one is stored,
* and an empty field on save means "keep the stored one" — which is what lets a typo in the URL be fixed
* without going to find the password again.
*/
type ConnectionView = {
configured: boolean;
url: string | null;
identity: string | null;
hasSecret: boolean;
version: string | null;
lastProbeAt: string | null;
lastProbeOk: boolean | null;
};
type TestResult = {
reachable: boolean;
authenticated: boolean;
version?: string;
setup?: boolean;
error?: string;
likelyAuth?: boolean;
};
const HINT = 'text-[11px] leading-relaxed text-muted-foreground';
/**
* NPM's admin UI port. Officer reaches it from the SERVER, so localhost here is the machine Officer runs
* on — and on a host where NPM is a container, its address on the docker network works whether or not the
* port is published. `docs/npm-api.md` has the measurements.
*/
const DEFAULT_URL = 'http://127.0.0.1:81';
const URL_HINT =
'Where NPM answers, reached from the server rather than from this browser. A trailing slash is trimmed. ' +
'If NPM runs as a container beside Officer, its address on the docker network works even with no ' +
'published port.';
const SECRET_HINT =
'The NPM admin password. Stored encrypted under this plugin’s own key and never returned by the ' +
'API — not to a member, not to you. Leave it empty to keep the one already stored.';
type FieldProps = {
label: string;
hint?: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
type?: string;
autoFocus?: boolean;
};
const Field = ({ label, hint, value, onChange, placeholder, type, autoFocus }: FieldProps) => (
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium">{label}</span>
<Input
value={value}
onChange={(ev) => onChange(ev.target.value)}
placeholder={placeholder}
type={type}
autoFocus={autoFocus}
autoComplete="off"
spellCheck={false}
/>
{hint && <span className={HINT}>{hint}</span>}
</label>
);
/** A 403 here means the account is not the owner — the one refusal worth explaining rather than reporting. */
const isForbidden = (error: unknown): boolean => (error as { status?: number })?.status === 403;
/**
* `useClient` throws `ApiError(status, message)` and nothing else — there is no `.body` to read, which is
* the bug `HostsPanel`'s `asFailure` documents at length. The message is already the server's own text:
* since 2026-08-25 the platform answers `{ error }` and the client unwraps it before throwing.
*
* `readableError` guards the case where that text is not Officer's at all — see `errors.ts`.
*/
const errorText = (error: unknown): string => readableError(error);
export const ConnectionPanel = () => {
const client = useClient();
// useClient() rebuilds its verbs every render, so it must never sit in a dependency list.
const clientRef = useRef(client);
clientRef.current = client;
const [view, setView] = useState<ConnectionView | null>(null);
const [forbidden, setForbidden] = useState(false);
const [url, setUrl] = useState('');
const [identity, setIdentity] = useState('');
const [secret, setSecret] = useState('');
const [test, setTest] = useState<TestResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<'test' | 'save' | 'delete' | null>(null);
const [confirming, setConfirming] = useState(false);
const [, npmChanged] = useNpmChanged();
const [, setParams] = useSearchParams();
/**
* Leave the settings view and tell the other panels to re-read.
*
* Both halves were missing and both were visible: after a successful save the form stayed open, and the
* hosts and certificates panels went on showing the failure they had loaded before a connection existed.
* The only remedy was a manual page refresh, which is what got reported.
*/
const settled = useCallback(() => {
setParams(
(p) => {
p.delete('config');
return p;
},
{ replace: true },
);
npmChanged();
}, [setParams, npmChanged]);
const load = useCallback(async () => {
try {
const next = await clientRef.current.get<ConnectionView>('/proxy-manager/_config');
setView(next);
setUrl(next.url ?? DEFAULT_URL);
setIdentity(next.identity ?? '');
setForbidden(false);
} catch (err) {
if (isForbidden(err)) return setForbidden(true);
setError(errorText(err));
}
}, []);
useEffect(() => {
void load();
}, [load]);
const runTest = async () => {
setBusy('test');
setError(null);
setTest(null);
try {
// The secret is sent only when the field was filled. Testing with the field empty checks the URL and
// nothing else, which is still useful — it is how you find out you are pointed at the wrong thing.
setTest(
await clientRef.current.post<TestResult>('/proxy-manager/_config/test', {
url,
identity: identity || undefined,
secret: secret || undefined,
}),
);
} catch (err) {
setError(errorText(err));
} finally {
setBusy(null);
}
};
const save = async () => {
setBusy('save');
setError(null);
try {
const next = await clientRef.current.put<ConnectionView>('/proxy-manager/_config', {
url,
identity,
// Omitted rather than sent empty — the server reads "absent" as keep, and an empty string as a
// secret would be stored verbatim.
...(secret ? { secret } : {}),
});
setView(next);
setSecret('');
setTest(null);
settled();
} catch (err) {
setError(errorText(err));
} finally {
setBusy(null);
}
};
const disconnect = async () => {
setBusy('delete');
setError(null);
try {
await clientRef.current.delete('/proxy-manager/_config');
setConfirming(false);
setTest(null);
setSecret('');
await load();
// Disconnecting is a change too — the panels must stop showing hosts that are no longer reachable.
npmChanged();
} catch (err) {
setError(errorText(err));
} finally {
setBusy(null);
}
};
if (forbidden) {
return (
<div className="flex size-full flex-col items-center justify-center gap-3 p-6 text-center">
<ShieldAlert className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Only the server owner can change this connection.</p>
<p className={`max-w-sm ${HINT}`}>
It is an Nginx Proxy Manager admin login, and NPM has no read-only role — anyone holding it can
redirect any hostname this machine serves. You can still see the hosts and certificates.
</p>
</div>
);
}
if (!view) return <div className="flex size-full items-center justify-center p-6 text-sm text-muted-foreground">Loading…</div>;
// An empty secret is fine on an existing connection and not on a new one — the server enforces this too,
// and would answer 400. Saying so here means the button explains itself instead of failing.
const canSave = !!url.trim() && !!identity.trim() && (view.hasSecret || !!secret);
return (
<div className="flex size-full flex-col overflow-auto">
<header className="flex items-center gap-2 border-b px-3 py-2">
<Plug className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">{view.configured ? 'Connection' : 'Connect Nginx Proxy Manager'}</span>
</header>
<div className="flex flex-col gap-4 p-4">
{view.configured && (
<div className="rounded-lg border p-3 text-xs">
<div className="flex items-center gap-2">
{view.lastProbeOk === false ? (
<TriangleAlert className="size-3.5 text-amber-500" />
) : (
<CheckCircle2 className="size-3.5 text-emerald-500" />
)}
<span className="font-medium">{view.url}</span>
{view.version && <span className="text-muted-foreground">NPM {view.version}</span>}
</div>
<p className={`mt-1 ${HINT}`}>
{view.identity}
{view.hasSecret ? ' · a secret is stored' : ' · no secret stored'}
{view.lastProbeAt ? ` · last checked ${new Date(view.lastProbeAt).toLocaleString()}` : ''}
</p>
</div>
)}
<Field label="URL" hint={URL_HINT} value={url} onChange={setUrl} placeholder={DEFAULT_URL} autoFocus={!view.configured} />
<Field
label="Email"
hint="NPM calls this the identity. It is the address you sign in to NPM with."
value={identity}
onChange={setIdentity}
placeholder="admin@example.com"
/>
<Field
label="Password"
hint={SECRET_HINT}
value={secret}
onChange={setSecret}
placeholder={view.hasSecret ? 'unchanged' : '••••••••'}
type="password"
/>
{test && (
<div className="rounded-lg border p-3 text-xs">
{test.authenticated ? (
<p className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="size-3.5" /> Reached NPM {test.version} and the credential works.
</p>
) : test.reachable ? (
<>
<p className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<TriangleAlert className="size-3.5" /> Reached NPM {test.version}, but not signed in.
</p>
<p className={`mt-1 ${HINT}`}>
{test.setup === false
? 'That NPM has not finished its own setup yet — create its first admin user in NPM before connecting.'
: (test.error ??
'Fill in the email and password to check them. NPM answers 404 when unauthenticated, so a "not found" here is almost always the credential.')}
</p>
</>
) : (
<>
<p className="flex items-center gap-2 text-destructive">
<TriangleAlert className="size-3.5" /> Could not reach it.
</p>
<p className={`mt-1 ${HINT}`}>{test.error}</p>
</>
)}
</div>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex flex-wrap items-center gap-2">
{/* Test comes first, and testing never stores anything — so a wrong password cannot replace a
working one just because someone wanted to check it. */}
<Button type="button" variant="outline" size="sm" onClick={() => void runTest()} disabled={!url.trim() || busy !== null}>
{busy === 'test' ? <Loader2 className="size-3.5 animate-spin" /> : null} Test
</Button>
<Button type="button" size="sm" onClick={() => void save()} disabled={!canSave || busy !== null}>
{busy === 'save' ? <Loader2 className="size-3.5 animate-spin" /> : null}
{view.configured ? 'Save' : 'Connect'}
</Button>
{view.configured &&
(confirming ? (
<>
<Button type="button" variant="destructive" size="sm" onClick={() => void disconnect()} disabled={busy !== null}>
{busy === 'delete' ? <Loader2 className="size-3.5 animate-spin" /> : null} Really disconnect
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setConfirming(false)}>
Cancel
</Button>
</>
) : (
<Button type="button" variant="ghost" size="sm" onClick={() => setConfirming(true)} disabled={busy !== null}>
<Trash2 className="size-3.5" /> Disconnect
</Button>
))}
</div>
{!view.configured && (
<p className={HINT}>
Nothing is created or changed in NPM by connecting. This reads what is there, and the only write
it ever makes is taking a host in or out of service.
</p>
)}
</div>
</div>
);
};