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 { useFofosChanged } from './channel'; /** * Where fofos is, and the token to reach it. Both the first-run setup and the permanent settings. * * One component for both — the same choice `plugins/proxy-manager/web/ConnectionPanel.tsx` makes, and * for the same reason: a connection edited months later goes through exactly the code path that * stored the first one, so there is no second path to rot. * * ── 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 token in its body. * A member who reaches this screen should read a sentence explaining that, not a raw refusal. * * ── The token is never loaded ── * * `GET /_config` returns `hasToken`, never the token. 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 credential again. */ type ConnectionView = { configured: boolean; url: string | null; hasToken: boolean; sections: string[] | null; lastProbeAt: string | null; lastProbeOk: boolean | null; }; type TestResult = { reachable: boolean; authenticated: boolean; sections?: string[]; error?: string; }; export const ConnectionPanel = () => { const client = useClient(); const clientRef = useRef(client); clientRef.current = client; const [view, setView] = useState(null); const [url, setUrl] = useState(''); const [token, setToken] = useState(''); const [busy, setBusy] = useState(false); const [result, setResult] = useState(null); const [error, setError] = useState(null); const [forbidden, setForbidden] = useState(false); const [, bump] = useFofosChanged(); const load = useCallback(async () => { try { const current = await clientRef.current.get('/fofos-admin/_config'); setView(current); setUrl(current.url ?? ''); } catch (failure) { // A 403 here is not a fault — it is a member looking at an owner-only screen, and it needs a // different sentence from "that did not work". if ((failure as { status?: number })?.status === 403) setForbidden(true); else setError(readableError(failure)); setView({ configured: false, url: null, hasToken: false, sections: null, lastProbeAt: null, lastProbeOk: null }); } }, []); useEffect(() => { void load(); }, [load]); const test = async () => { setBusy(true); setError(null); try { // Sent only when typed. Omitted, the server tests what is STORED — which is how "is my // connection still good?" is answered without retyping a secret. const body: Record = {}; if (url.trim()) body.url = url.trim(); if (token.trim()) body.token = token.trim(); setResult(await clientRef.current.post('/fofos-admin/_config/test', body)); } catch (failure) { setError(readableError(failure)); } finally { setBusy(false); } }; const save = async () => { setBusy(true); setError(null); try { const body: Record = { url: url.trim() }; if (token.trim()) body.token = token.trim(); const saved = await clientRef.current.put( '/fofos-admin/_config', body, ); setView(saved); setResult(saved.probe ?? null); // Cleared on success: it is stored now, and keeping it on screen serves nobody. setToken(''); // Everything else re-reads. Saving a connection is the one action that changes what every other // panel can see. bump(); } catch (failure) { setError(readableError(failure)); } finally { setBusy(false); } }; const forget = async () => { setBusy(true); try { setView(await clientRef.current.delete('/fofos-admin/_config')); setUrl(''); setToken(''); setResult(null); bump(); } catch (failure) { setError(readableError(failure)); } finally { setBusy(false); } }; if (forbidden) { return (

Only the owner can change this connection.

The token stored here can rewrite the whole public site, so setting it belongs to whoever owns the server. You can still edit content.

); } if (!view) { return
Loading…
; } return (

The fofos server

Where the site lives, and a token to edit it. The token is fofos's FOFOS_API_TOKEN.

{view.configured && ( )}
{error &&

{error}

} {result && (
{result.authenticated ? ( ) : ( )} {result.authenticated ? `Connected. ${result.sections?.length ?? 0} sections available.` : (result.error ?? 'That did not work.')}
)} {view.configured && !result && (

{view.lastProbeOk === null ? 'Saved, and not yet checked.' : view.lastProbeOk ? `Last checked ${view.lastProbeAt ? new Date(view.lastProbeAt).toLocaleString() : ''} — working.` : 'The last check failed. Press Test to try again.'}

)}
); };