Administration is emptied, and starts again with a Database block
the address-and-token form is gone from the screen and ConnectionPanel.tsx with it. leaving an unreferenced component behind would have been worse than deleting it: it is in the history, and a file nothing renders rots without anyone noticing. in its place, Administration is a LIST OF BLOCKS rather than one form. it will hold several unrelated settings, and giving each a titled block now means the second one moves nothing — a single form would have to be broken up the first time anything joined it. the first block is Database, and it is empty on purpose. it says "nothing to set yet" rather than rendering nothing, because a section that draws blank reads as one somebody abandoned. what goes in it is not decided, and inventing fields to fill the space would be deciding it in the wrong place. WHAT THIS COSTS, plainly: there is now no way to configure this plugin from the screen at all. the server half is untouched — `/_config` still stores a URL and a token and the proxy in api/router.ts still reads them — but nothing can set them. that is consistent with the gate being hardcoded false rather than a second breakage: both are the same unfinished change, and neither is worth half-fixing while the shape of the connection is being replaced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -35,7 +35,7 @@ import type { PluginManifest } from '@@/plugins/manifest';
|
||||
*/
|
||||
export const manifest: PluginManifest = {
|
||||
publisher: 'officerdev',
|
||||
version: '2026.08.29.04',
|
||||
version: '2026.08.29.05',
|
||||
platform: '>=1.0.0',
|
||||
|
||||
label: 'Fofos',
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Database } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Administration — this plugin's own machinery, not the client's site.
|
||||
*
|
||||
* ── It is a list of blocks, and it starts with one ──
|
||||
*
|
||||
* Not one form. Administration will hold several unrelated settings, and giving each its own titled
|
||||
* block from the start means adding the second one moves nothing — as against a single form that has
|
||||
* to be broken up the first time anything joins it.
|
||||
*
|
||||
* ── What used to be here ──
|
||||
*
|
||||
* An address and a token: where fofos is, over HTTP. That is gone. It has NOT been replaced yet, so
|
||||
* nothing can currently configure this plugin — which is why `useReady` stays hardcoded false and
|
||||
* every other section stays locked. The two are the same change and neither is finished.
|
||||
*
|
||||
* The server half is untouched: `/_config` still stores a URL and a token, and the proxy in
|
||||
* `api/router.ts` still reads them. Only the way in from the screen is gone.
|
||||
*/
|
||||
|
||||
/** One titled block. The shape every Administration setting gets, so the second one costs nothing. */
|
||||
const Block = ({
|
||||
icon: Icon,
|
||||
title,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
icon: typeof Database;
|
||||
title: string;
|
||||
hint: string;
|
||||
children?: React.ReactNode;
|
||||
}) => (
|
||||
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium">
|
||||
<Icon className="size-4" />
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">{hint}</p>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
|
||||
export const AdministrationPanel = () => (
|
||||
<div className="flex size-full flex-col gap-4 overflow-auto p-5">
|
||||
<Block icon={Database} title="Database" hint="Where the fofos site's own data lives.">
|
||||
{/* Deliberately empty. The block exists so the shape is settled and so the screen says what is
|
||||
coming rather than looking like a section nobody finished — but nothing is decided about what
|
||||
goes in it, and guessing at fields would be a decision made in the wrong place. */}
|
||||
<p className="text-xs text-muted-foreground/70">Nothing to set yet.</p>
|
||||
</Block>
|
||||
</div>
|
||||
);
|
||||
@@ -1,240 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { DEFAULT_SECTION, isSectionKey, sectionFor, type SectionKey } from './sections';
|
||||
import { ConnectionPanel } from './ConnectionPanel';
|
||||
import { AdministrationPanel } from './AdministrationPanel';
|
||||
import { MessagesPanel } from './MessagesPanel';
|
||||
import { PlannedSection } from './PlannedSection';
|
||||
import { useReady } from './useReady';
|
||||
@@ -38,7 +38,7 @@ import { useReady } from './useReady';
|
||||
*/
|
||||
const PANELS: Partial<Record<SectionKey, () => React.JSX.Element>> = {
|
||||
messages: MessagesPanel,
|
||||
administration: ConnectionPanel,
|
||||
administration: AdministrationPanel,
|
||||
};
|
||||
|
||||
export const DetailPanel = () => {
|
||||
|
||||
Reference in New Issue
Block a user