Files
proxy-manager/web/ConnectionPanel.tsx
T
pastilhasandClaude Opus 5 ee8edd3709 the settings form, and the branch that never ran
the plugin could not be configured. /_config had GET, PUT, DELETE and a test
that validates without storing, and nothing rendered any of it — the
connection had to be inserted by hand.

ConnectionPanel is both the wizard and the settings page, one component, the
same choice jellyfin's ConnectionSection makes and for the same reason: a
connection edited months later goes through exactly the path that stored the
first one. HostsPanel shows it when nothing is configured and on ?config=1,
which is in the URL so it survives a refresh. test comes before save and
stores nothing, so a wrong password cannot replace a working one. the secret
field starts empty even when one is stored, and empty means keep — that is
what lets a typo in the URL be fixed without going to find the password.

and while wiring the unconfigured case, it turned out that case never fired.

both panels read `(error as { body?: Failure }).body` to decide whether a
failure was unconfigured, unauthenticated or unreachable. useClient throws
ApiError(status, message) and has no `.body` at all, so that was always
undefined: every failure fell through to the generic message, and the careful
explanation of NPM's 404-means-credential quirk was written, shipped and
unreachable. asFailure now reads the STATUS, which the router already chooses
deliberately — 409 nothing stored, 503 no answer, 502 answered and refused.

owner-only is explained rather than reported. a member hitting 403 here reads
why: it is an admin login and NPM has no read-only role.

still not installed, still no db:push.

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

324 lines
12 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';
/**
* 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.
*/
const errorText = (error: unknown): string =>
error instanceof Error ? error.message : 'Something went wrong.';
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>
);
};