manifest, one table, a router and two panels. Discovery is by convention, so
this is the point at which the directory stops being a directory and becomes
something Officer can install.
The manifest holds only what a listing cannot say. One permission, grantable,
with ownerOnly on /_config — reading and editing the site is exactly what a
member might be brought in for, but the token is unrestricted write authority
over everything the public sees, so handing it over is the owner's.
db/schema.ts is ONE table and should stay that way. fofos owns its own Postgres
and its own media store; this holds a URL and a token. That is what makes the
strongest thing about this plugin true: no operation on it can destroy the
client's work, checkbox ticked or not, because none of it was ever here. A cache
would be the thin end of that.
The router mostly forwards. fofos's /api/admin already answers in the shape a
panel wants, so re-wrapping it would be a second place for the contract to drift.
Two things in it are load-bearing rather than style:
registerProxiedPrefix('/api/fofos-admin/media'). bodyParser calls parseBody on
multipart across the whole protected router, and Hono then caches the parsed
FormData — so asking for the bytes afterwards re-serialises them under a NEW
boundary while the ORIGINAL content-type header is what gets forwarded. Header
and body disagree and every upload fails with "Unexpected end of form".
Narrow to the one route: everything else is JSON that is fine to parse.
The catch-all is LAST in the file. Hono runs matching handlers in registration
order, so /* above /_config would swallow it and the settings form would
forward itself to fofos, which has no such endpoint — a 404 from a plugin that
is working perfectly.
Panels are a nav and a detail pane that never speak to each other: the nav writes
?section=, the detail pane reads it. Real <Link>s rather than the buttons
proxy-manager uses — AGENTS.md says nav items are links, and the file documenting
that pattern says not to copy the older one. <Link> and not <NavLink> because
active state here is a query parameter and NavLink compares paths, which would
light every row at once.
Which component renders which section is a MAP, not a conditional on `built`.
The first version read `built ? <MessagesPanel/> : <Planned/>`, which is correct
only while exactly one section exists — the second one finished would have
rendered Messages under its own name.
Messages is the only section built, chosen because it is one read-only route: it
proves the path from an Officer panel through this proxy to fofos and back with
nothing else able to be wrong at the same time. The other eight are in the nav,
dimmed and labelled, saying what they will do and that the old admin still edits
them. A nav that hides what is unfinished gives no sense of what the thing is for.
Typechecked against the platform — and the typechecker was verified to actually
cover plugins/ by planting an error and watching it fail, because a clean run
that does not include your files is worse than no run.
NOT yet: installed, or run against a live fofos. Nothing here has been through
the permission gate or a real request.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
241 lines
8.3 KiB
TypeScript
241 lines
8.3 KiB
TypeScript
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<ConnectionView | null>(null);
|
|
const [url, setUrl] = useState('');
|
|
const [token, setToken] = useState('');
|
|
|
|
const [busy, setBusy] = useState(false);
|
|
const [result, setResult] = useState<TestResult | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [forbidden, setForbidden] = useState(false);
|
|
|
|
const [, bump] = useFofosChanged();
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const current = await clientRef.current.get<ConnectionView>('/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<string, string> = {};
|
|
if (url.trim()) body.url = url.trim();
|
|
if (token.trim()) body.token = token.trim();
|
|
|
|
setResult(await clientRef.current.post<TestResult>('/fofos-admin/_config/test', body));
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const save = async () => {
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const body: Record<string, string> = { url: url.trim() };
|
|
if (token.trim()) body.token = token.trim();
|
|
|
|
const saved = await clientRef.current.put<ConnectionView & { probe?: TestResult }>(
|
|
'/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<ConnectionView>('/fofos-admin/_config'));
|
|
setUrl('');
|
|
setToken('');
|
|
setResult(null);
|
|
bump();
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
if (forbidden) {
|
|
return (
|
|
<div className="flex size-full flex-col items-center justify-center gap-2 p-6 text-center">
|
|
<ShieldAlert className="size-6 text-muted-foreground/60" />
|
|
<p className="text-sm">Only the owner can change this connection.</p>
|
|
<p className="max-w-sm text-xs text-muted-foreground">
|
|
The token stored here can rewrite the whole public site, so setting it belongs to whoever owns the
|
|
server. You can still edit content.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!view) {
|
|
return <div className="flex size-full items-center justify-center p-6 text-sm text-muted-foreground">Loading…</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="flex size-full flex-col gap-4 overflow-auto p-5">
|
|
<div className="space-y-1">
|
|
<h2 className="flex items-center gap-2 text-sm font-medium">
|
|
<Plug className="size-4" />
|
|
The fofos server
|
|
</h2>
|
|
<p className="text-xs text-muted-foreground">
|
|
Where the site lives, and a token to edit it. The token is fofos's <code>FOFOS_API_TOKEN</code>.
|
|
</p>
|
|
</div>
|
|
|
|
<label className="space-y-1.5">
|
|
<span className="text-xs text-muted-foreground">Address</span>
|
|
<Input
|
|
value={url}
|
|
onChange={(event) => setUrl(event.target.value)}
|
|
placeholder="http://localhost:3010"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
/>
|
|
<span className="block text-xs text-muted-foreground/80">
|
|
The live fofos server, not the published static site — the static one serves no API.
|
|
</span>
|
|
</label>
|
|
|
|
<label className="space-y-1.5">
|
|
<span className="text-xs text-muted-foreground">Token</span>
|
|
<Input
|
|
type="password"
|
|
value={token}
|
|
onChange={(event) => setToken(event.target.value)}
|
|
placeholder={view.hasToken ? 'A token is stored — leave empty to keep it' : ''}
|
|
autoComplete="new-password"
|
|
spellCheck={false}
|
|
/>
|
|
</label>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button size="sm" onClick={() => void save()} disabled={busy || !url.trim()}>
|
|
{busy && <Loader2 className="size-3.5 animate-spin" />}
|
|
Save
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={() => void test()} disabled={busy || (!url.trim() && !view.configured)}>
|
|
Test
|
|
</Button>
|
|
{view.configured && (
|
|
<Button variant="ghost" size="sm" onClick={() => void forget()} disabled={busy}>
|
|
<Trash2 className="size-3.5" />
|
|
Forget
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
|
|
{result && (
|
|
<div
|
|
className={`flex items-start gap-2 rounded-md p-3 text-xs ${
|
|
result.authenticated ? 'bg-success/15 text-success' : 'bg-destructive/15 text-destructive'
|
|
}`}
|
|
>
|
|
{result.authenticated ? (
|
|
<CheckCircle2 className="mt-0.5 size-3.5 shrink-0" />
|
|
) : (
|
|
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" />
|
|
)}
|
|
<span>
|
|
{result.authenticated
|
|
? `Connected. ${result.sections?.length ?? 0} sections available.`
|
|
: (result.error ?? 'That did not work.')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{view.configured && !result && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{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.'}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|