Files
proxy-manager/web/ConnectionPanel.tsx
T
pastilhasandClaude Opus 5 83a8558e60 do not print a proxy's error page at somebody
first real test of the settings form returned openresty's 502 Bad Gateway
document, markup and all, into the panel. installing this plugin restarts
officer, and a request made inside that window reaches nginx proxy manager,
finds nothing behind it, and gets a web page back. useClient throws the
response body as the message, so the panel printed it.

worth keeping the irony: this plugin manages the proxy whose error page it
could not read.

the check is the body's SHAPE, not the status, because there is no status that
separates them — api/router.ts uses 502 for "NPM answered and refused", which
is a real answer we must not swallow. ours arrives as a sentence, theirs as a
document. gateway status AND html means the hop in front of officer answered;
html on its own still is not a sentence and says so without the tags.

all three panels go through it now.

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

326 lines
13 KiB
TypeScript
Raw 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';
/**
* 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 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);
} 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();
} 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>
);
};