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) => ( ); /** 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(null); const [forbidden, setForbidden] = useState(false); const [url, setUrl] = useState(''); const [identity, setIdentity] = useState(''); const [secret, setSecret] = useState(''); const [test, setTest] = useState(null); const [error, setError] = useState(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('/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('/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('/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 (

Only the server owner can change this connection.

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.

); } if (!view) return
Loading…
; // 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 (
{view.configured ? 'Connection' : 'Connect Nginx Proxy Manager'}
{view.configured && (
{view.lastProbeOk === false ? ( ) : ( )} {view.url} {view.version && NPM {view.version}}

{view.identity} {view.hasSecret ? ' · a secret is stored' : ' · no secret stored'} {view.lastProbeAt ? ` · last checked ${new Date(view.lastProbeAt).toLocaleString()}` : ''}

)} {test && (
{test.authenticated ? (

Reached NPM {test.version} and the credential works.

) : test.reachable ? ( <>

Reached NPM {test.version}, but not signed in.

{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.')}

) : ( <>

Could not reach it.

{test.error}

)}
)} {error &&

{error}

}
{/* Test comes first, and testing never stores anything — so a wrong password cannot replace a working one just because someone wanted to check it. */} {view.configured && (confirming ? ( <> ) : ( ))}
{!view.configured && (

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.

)}
); };