diff --git a/api/connection.ts b/api/connection.ts new file mode 100644 index 0000000..6446315 --- /dev/null +++ b/api/connection.ts @@ -0,0 +1,107 @@ +import { db } from 'officerdb/db'; +import { encryptSecret, decryptSecret } from 'officerdb/crypto'; +import { eq } from 'drizzle-orm'; +import { fofosAdminConnection, type FofosAdminConnection } from '../db/schema'; +import { normaliseUrl, type FofosCredentials } from './fofos-client'; + +/** + * Reading and writing the one connection row. + * + * The token is encrypted under this plugin's own purpose key, so a leak of that key opens this column + * and nothing else — the per-purpose split the platform's secret store already decided on. + * + * **The token never leaves this module in plaintext except into `fofosRequest`.** No route returns + * it, at any permission level, to anybody, including the owner. A form that wants to show "a token is + * set" gets a boolean. + */ + +const PURPOSE = 'fofos-admin'; + +export type ConnectionView = { + configured: boolean; + url: string | null; + /** Never the token itself. Only whether one is stored. */ + hasToken: boolean; + sections: string[] | null; + lastProbeAt: string | null; + lastProbeOk: boolean | null; +}; + +const EMPTY: ConnectionView = { + configured: false, + url: null, + hasToken: false, + sections: null, + lastProbeAt: null, + lastProbeOk: null, +}; + +const row = async (): Promise => { + const rows = await db.select().from(fofosAdminConnection).limit(1); + return rows[0] ?? null; +}; + +export async function readConnection(): Promise { + const found = await row(); + if (!found) return EMPTY; + + return { + configured: true, + url: found.url, + hasToken: found.token.length > 0, + sections: found.sections ?? null, + lastProbeAt: found.lastProbeAt ? found.lastProbeAt.toISOString() : null, + lastProbeOk: found.lastProbeOk === null ? null : found.lastProbeOk === 1, + }; +} + +/** Decrypted, for the client only. Never serialise the result of this. */ +export async function readCredentials(): Promise { + const found = await row(); + if (!found) return null; + return { url: found.url, token: decryptSecret(PURPOSE, found.token) }; +} + +export type SaveParams = { url: string; token?: string }; + +/** + * `token` omitted means "keep the stored one". + * + * So a typo in the URL can be corrected without going to find the token again, and a settings form + * never has to round-trip a secret in order to send it back. + */ +export async function saveConnection(params: SaveParams): Promise { + const url = normaliseUrl(params.url); + const existing = await row(); + + if (!existing && !params.token) { + throw new Error('A token is required the first time the connection is set.'); + } + + const token = params.token ? encryptSecret(PURPOSE, params.token) : (existing?.token as string); + + if (existing) { + await db + .update(fofosAdminConnection) + .set({ url, token, updatedAt: new Date() }) + .where(eq(fofosAdminConnection.id, existing.id)); + } else { + await db.insert(fofosAdminConnection).values({ url, token }); + } + + return readConnection(); +} + +export async function deleteConnection(): Promise { + await db.delete(fofosAdminConnection); +} + +export async function recordProbe(sections: string[] | null, ok: boolean): Promise { + const existing = await row(); + if (!existing) return; + + await db + .update(fofosAdminConnection) + .set({ sections, lastProbeAt: new Date(), lastProbeOk: ok ? 1 : 0 }) + .where(eq(fofosAdminConnection.id, existing.id)); +} diff --git a/api/fofos-client.ts b/api/fofos-client.ts new file mode 100644 index 0000000..71fb9f2 --- /dev/null +++ b/api/fofos-client.ts @@ -0,0 +1,143 @@ +/** + * Talking to the fofos server. + * + * A thin client on purpose. fofos's `/api/admin` already answers in the shape a panel wants — every + * route returns `{ data: … }`, and a mutation returns the whole section it belongs to — so there is + * nothing here to reshape. What this owns is the credential, the failure vocabulary, and the one + * decision about what a non-answer means. + * + * See `docs/SURFACES.md` in `fofos/platform` for the routes. + */ + +export type FofosCredentials = { url: string; token: string }; + +/** No trailing slash, so joining a path never produces a double one. */ +export const normaliseUrl = (url: string): string => url.trim().replace(/\/+$/, ''); + +/** + * A failure with the two things a panel needs to say something useful: whether fofos answered at all, + * and whether it refused the credential. + * + * Those are genuinely different problems — one is "the server is down or the URL is wrong", the other + * is "the token is wrong" — and a panel that cannot tell them apart can only say "something went + * wrong", which sends someone to check the wrong half. + */ +export class FofosError extends Error { + constructor( + message: string, + readonly status: number | null, + /** fofos refused the credential. `status` is 401. */ + readonly unauthorised = false, + ) { + super(message); + this.name = 'FofosError'; + } +} + +/** `null` status means the request never got an answer — DNS, refused connection, timeout. */ +const unreachable = (url: string, cause: unknown) => + new FofosError( + `Could not reach fofos at ${url}. ${cause instanceof Error ? cause.message : String(cause)}`, + null, + ); + +type RequestOptions = { + method?: string; + path: string; + search?: string; + /** Already-serialised. Forwarded as given so what fofos receives is what the caller sent. */ + body?: string | FormData | null; + contentType?: string | null; +}; + +export type FofosResponse = { status: number; body: string; contentType: string | null }; + +/** + * One request, returning fofos's own answer verbatim — including the bad ones. + * + * It does NOT throw on a 4xx or 5xx. fofos's error envelope is more informative than anything this + * could substitute (it names the field), and the caller is forwarding to a panel that should see what + * the server actually said. Only a request that got no answer at all throws, because there is nothing + * to forward. + */ +export async function fofosRequest( + credentials: FofosCredentials, + options: RequestOptions, +): Promise { + const url = `${normaliseUrl(credentials.url)}/api/admin${options.path}${options.search ?? ''}`; + + const headers: Record = { Authorization: `Bearer ${credentials.token}` }; + // Left unset for FormData: fetch has to write its own, including the multipart boundary, and a + // hand-set Content-Type here produces a body the other end cannot parse. + if (options.contentType) headers['Content-Type'] = options.contentType; + + let response: Response; + try { + response = await fetch(url, { + method: options.method ?? 'GET', + headers, + body: options.body ?? undefined, + // fofos is a small server on the same network and every one of these is a database read. A + // request still hanging after this is not going to complete. + signal: AbortSignal.timeout(30_000), + }); + } catch (cause) { + throw unreachable(normaliseUrl(credentials.url), cause); + } + + return { + status: response.status, + body: await response.text(), + contentType: response.headers.get('content-type'), + }; +} + +export type ProbeResult = { + reachable: boolean; + authenticated: boolean; + sections?: string[]; + error?: string; +}; + +/** + * Is this URL a fofos server, and does it accept this token? + * + * Both questions at once, because a person setting this up has one form and should not have to work + * out which half is wrong. `GET /api/admin` sits behind the guard for exactly this reason: a 200 is + * both answers, and a 401 separates them. + */ +export async function probe(credentials: FofosCredentials): Promise { + let response: FofosResponse; + try { + response = await fofosRequest(credentials, { path: '' }); + } catch (error) { + return { reachable: false, authenticated: false, error: error instanceof Error ? error.message : String(error) }; + } + + if (response.status === 401) { + return { reachable: true, authenticated: false, error: 'fofos refused that token.' }; + } + + if (response.status !== 200) { + return { + reachable: true, + authenticated: false, + // A 404 here is the interesting one: something answered, but it is not serving /api/admin. That + // is usually a URL pointing at the built static site rather than at the live server. + error: + response.status === 404 + ? 'That address answered, but it is not serving /api/admin. Is it pointing at the static site rather than the fofos server?' + : `fofos answered ${response.status}.`, + }; + } + + try { + const parsed = JSON.parse(response.body) as { data?: { service?: string; sections?: string[] } }; + if (parsed.data?.service !== 'fofos') { + return { reachable: true, authenticated: false, error: 'That address answered, but it is not fofos.' }; + } + return { reachable: true, authenticated: true, sections: parsed.data.sections ?? [] }; + } catch { + return { reachable: true, authenticated: false, error: 'That address answered with something that is not JSON.' }; + } +} diff --git a/api/router.ts b/api/router.ts new file mode 100644 index 0000000..bdbb3ef --- /dev/null +++ b/api/router.ts @@ -0,0 +1,206 @@ +import type { Context } from 'hono'; +import { createRouter, type HonoVariables } from '@@/create-router'; +import { registerProxiedPrefix } from '@@/sidecar/proxied-prefixes'; +import { deleteConnection, readConnection, readCredentials, recordProbe, saveConnection } from './connection'; +import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client'; + +/** + * Mounted at `/api/fofos-admin`. + * + * ── Mostly a pass-through, and that is the design ── + * + * fofos's `/api/admin` is already the right shape for a panel: every route answers `{ data: … }`, and + * a mutation returns the whole section it belongs to. Re-wrapping that here would add a second place + * for the contract to be described and a second place for it to drift. + * + * So this holds the credential and forwards. What it does NOT do is decide what fofos's answers mean: + * a 404 from fofos reaches the panel as a 404, because fofos's error envelope names the field and + * nothing here could improve on that. + * + * The exception is `/_config`, which is this plugin's own and never leaves the machine. + * + * ── Permissions ── + * + * The platform's gate decides who gets here, and `/_config` is `ownerOnly` in the manifest. Nothing in + * this file checks again: two places deciding the same thing is how they come to disagree. + */ +export const router = createRouter(); + +/** + * The upload path forwards a body it must not have parsed. This is load-bearing. + * + * `bodyParser` runs across the whole protected router and calls `parseBody` on `multipart/form-data`. + * Hono then caches the parsed FormData, so asking for the bytes afterwards RE-SERIALISES them under a + * NEW multipart boundary — while the original `content-type` header, naming the old one, is what gets + * forwarded. Header and body disagree and fofos rejects every upload with "Unexpected end of form". + * + * Registering the prefix tells `bodyParser` to leave these requests alone. Narrow on purpose — one + * route, not the whole plugin — because everything else here is JSON that is genuinely fine to parse, + * and claiming more than is needed turns an opt-out into a habit. + */ +registerProxiedPrefix('/api/fofos-admin/media'); + +/** fofos's own answer, passed through with its status and content type intact. */ +const passThrough = (response: FofosResponse) => + new Response(response.body, { + status: response.status, + headers: { 'content-type': response.contentType ?? 'application/json' }, + }); + +/** + * A shaped failure for the two things that can go wrong before fofos answers at all. + * + * `unconfigured` is the one panels branch on: it is a setup step rather than an error, and rendering + * it as a broken list would tell somebody their site was down when they have simply not connected it + * yet. + */ +const noConnection = (ctx: Context<{ Variables: HonoVariables }>) => + ctx.json({ error: 'No fofos connection is configured.', unconfigured: true }, 409); + +const unreachable = (ctx: Context<{ Variables: HonoVariables }>, error: unknown) => { + if (error instanceof FofosError) { + return ctx.json({ error: error.message, unreachable: error.status === null }, 502); + } + return ctx.json({ error: error instanceof Error ? error.message : String(error) }, 500); +}; + +// ── Everything the panels read and write ───────────────────────────────────────────────────────── + +/** + * Uploads. Registered above so its body arrives untouched. + * + * Buffered rather than streamed: Bun's fetch can take a stream, but the platform is a hop in the + * middle and a half-forwarded upload that fails mid-flight is harder to reason about than one that is + * whole before it leaves. `arrayBuffer()` here is the ORIGINAL bytes with the ORIGINAL boundary, + * which is exactly why the original `content-type` can be forwarded with them. + */ +router.post('/media', async (ctx) => { + const credentials = await readCredentials(); + if (!credentials) return noConnection(ctx); + + try { + const body = await ctx.req.arrayBuffer(); + const response = await fofosRequest(credentials, { + method: 'POST', + path: '/media', + body: new Blob([body]) as unknown as FormData, + contentType: ctx.req.header('content-type') ?? null, + }); + return passThrough(response); + } catch (error) { + return unreachable(ctx, error); + } +}); + +// ── Settings — owner only, by the manifest ─────────────────────────────────────────────────────── + +router.get('/_config', async (ctx) => ctx.json(await readConnection())); + +router.put('/_config', async (ctx) => { + const payload = (await ctx.req.json().catch(() => null)) as { url?: string; token?: string } | null; + if (!payload?.url?.trim()) return ctx.json({ error: 'A fofos URL is required.' }, 400); + + try { + const saved = await saveConnection({ url: payload.url, token: payload.token?.trim() || undefined }); + + // Probed immediately, so the form can say whether it works rather than only that it saved. A + // failure here is NOT a failed save — the connection is stored either way, which is what lets + // somebody fix a token without re-entering the URL. + const credentials = await readCredentials(); + if (credentials) { + const result = await probe(credentials); + await recordProbe(result.sections ?? null, result.authenticated); + return ctx.json({ ...(await readConnection()), probe: result }); + } + + return ctx.json(saved); + } catch (error) { + return ctx.json({ error: error instanceof Error ? error.message : String(error) }, 400); + } +}); + +router.delete('/_config', async (ctx) => { + await deleteConnection(); + return ctx.json(await readConnection()); +}); + +/** + * Check a URL and token WITHOUT storing them. + * + * So a wrong token never replaces a working one — the same reason proxy-manager's test route exists. + * When the token is omitted it tests the STORED one, which is how "is my connection still good?" is + * answered without anybody having to retype a secret. + */ +router.post('/_config/test', async (ctx) => { + const payload = (await ctx.req.json().catch(() => null)) as { url?: string; token?: string } | null; + + const stored = await readCredentials(); + const url = payload?.url?.trim() || stored?.url; + const token = payload?.token?.trim() || stored?.token; + + if (!url || !token) { + return ctx.json({ reachable: false, authenticated: false, error: 'A URL and a token are needed.' }, 400); + } + + const result = await probe({ url, token }); + + // Only when testing what is actually stored. Recording a probe of some other credential would put a + // verdict about a different server against this row. + if (!payload?.url && !payload?.token && stored) { + await recordProbe(result.sections ?? null, result.authenticated); + } + + return ctx.json(result); +}); + +/** + * Is this plugin usable? Asked once by the detail panel rather than by all nine sections. + * + * Not owner-only, and not under `/_config` for that reason: a member needs to know whether to render a + * section or a "not connected yet" message, and that answer contains no credential. + */ +router.get('/status', async (ctx) => { + const connection = await readConnection(); + return ctx.json({ configured: connection.configured, lastProbeOk: connection.lastProbeOk }); +}); + +/** + * Everything else, forwarded verbatim. + * + * A catch-all rather than a route per section, because the sections are fofos's to define — see its + * `docs/SURFACES.md`. Enumerating them here would mean this plugin needs a release every time fofos + * adds one, which is a coupling with nothing to show for it. + * + * ── LAST in the file, and that is load-bearing ── + * + * Hono runs every matching handler in REGISTRATION order, so `/*` declared above `/_config` would + * swallow it — the settings routes would forward themselves to fofos, which has no such endpoint, and + * the settings form would 404 against a plugin that was working perfectly. Everything this plugin + * serves itself is registered before this point. + */ +router.all('/*', async (ctx) => { + const credentials = await readCredentials(); + if (!credentials) return noConnection(ctx); + + const url = new URL(ctx.req.url); + // Everything after the mount, so `/gallery/3/media` reaches fofos as `/api/admin/gallery/3/media`. + const path = url.pathname.slice(url.pathname.indexOf('/fofos-admin') + '/fofos-admin'.length) || ''; + + const method = ctx.req.method; + // Read as TEXT and forwarded as text, so what fofos parses is what the panel sent. Safe after + // bodyParser because Hono caches the body — and correct because these are all JSON. + const body = method === 'GET' || method === 'HEAD' ? null : await ctx.req.text(); + + try { + const response = await fofosRequest(credentials, { + method, + path, + search: url.search, + body, + contentType: body ? 'application/json' : null, + }); + return passThrough(response); + } catch (error) { + return unreachable(ctx, error); + } +}); diff --git a/db/schema.ts b/db/schema.ts new file mode 100644 index 0000000..eab7cd8 --- /dev/null +++ b/db/schema.ts @@ -0,0 +1,61 @@ +import { pgTable, serial, text, timestamp, integer } from 'drizzle-orm/pg-core'; + +/** + * The connection to the fofos server. One row, and the ONLY thing this plugin stores. + * + * ── Why there is nothing else here ── + * + * Every temptation to add a table to this file should be resisted, and it is worth writing down why + * rather than relying on taste. fofos owns its own Postgres and its own media store; this plugin + * reaches them over HTTP. If the client's collections and photographs lived here instead, they would + * live under Officer's plugin installer — which drops a plugin's tables when the "delete its data" + * checkbox is ticked. + * + * So the strongest thing that can be said about this plugin is a consequence of this file being one + * table long: **no operation on it can destroy the client's work.** Deleting it removes a URL and a + * token, and the site is untouched. + * + * A cache would be the thin end of it. If a panel ever needs one, it belongs in memory or in the + * browser, not in a table that outlives the request. + * + * ── Why not core `service_connections` ── + * + * It fits the shape — `url`, an encrypted `secret` — but its `ServiceName` is a closed TypeScript + * union, so using it means editing the platform to add a name, and a plugin should install into a + * platform it did not have to change. The same reasoning `plugins/proxy-manager/db/schema.ts` gives, + * and worth revisiting on the day that union opens. + */ +export const fofosAdminConnection = pgTable('fofos_admin_connection', { + id: serial('id').primaryKey(), + + /** Where the fofos server is — the origin its API is served from. Normalised without a trailing slash. */ + url: text('url').notNull(), + + /** + * fofos's `FOFOS_API_TOKEN`, encrypted with `encryptSecret('fofos-admin', …)` — its own purpose + * key, so a leak of that one opens this column and nothing else. + * + * **Never returned across the API boundary**, at any level, to anybody, including the owner. A + * settings form asking "is one set" gets a boolean. + * + * There is exactly one of these rather than one per user, and that is not laziness: fofos has a + * single token and no notion of accounts. WHO is editing is a question Officer answers with its own + * accounts and permissions, in front of this. + */ + token: text('token').notNull(), + + /** From the last successful probe — which sections that server says it serves. Diagnostic only. */ + sections: text('sections').array(), + + lastProbeAt: timestamp('last_probe_at', { withTimezone: true }), + /** + * `integer` rather than `boolean` for the same reason proxy-manager uses one: three states, not + * two. Null means never probed, which reads differently from probed-and-failed. + */ + lastProbeOk: integer('last_probe_ok'), + + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}); + +export type FofosAdminConnection = typeof fofosAdminConnection.$inferSelect; diff --git a/manifest.ts b/manifest.ts new file mode 100644 index 0000000..d09366d --- /dev/null +++ b/manifest.ts @@ -0,0 +1,64 @@ +import type { PluginManifest } from '@@/plugins/manifest'; + +/** + * Fofos — the fofos website's admin, inside Officer. + * + * ── What it replaces ── + * + * A server-rendered admin on its own subdomain with its own argon2 passwords, its own session cookie + * and its own `/admin/setup` bootstrap. Every part of that duplicated something Officer already does, + * and none of it could be shared: giving somebody access to the machine still meant giving them + * access to the admin, separately, by hand. That is the whole reason this is a plugin. + * + * ── It holds no content ── + * + * fofos owns its own database and its own media store, and this reads and writes them over HTTP. The + * only thing in Officer's database is the connection — a URL and a token. + * + * That is a deliberate property rather than a convenience. **Deleting this plugin cannot lose a + * photograph or a line of the client's copy**, with the "delete its data" checkbox ticked or not, + * because none of it was ever here. The alternative — a `db/schema.ts` carrying fofos's own tables — + * would have put every one of them under an installer that is allowed to drop them. + * + * ── The permission is grantable; the credential is not ── + * + * Reading and editing the site is exactly what a member might be brought in to do. The TOKEN is a + * different matter: it is unrestricted write authority over everything the public sees, so `/_config` + * is `ownerOnly` — the split that field exists for, and the same shape proxy-manager and Jellyfin use + * for the same reason. + * + * ── No `screens` ── + * + * One screen at the mount prefix, labelled `label`. The nine sections are `?section=` on that one + * screen rather than nine routes: they are panels of one workspace, and `screens` is for a plugin + * contributing genuinely separate destinations. + */ +export const manifest: PluginManifest = { + publisher: 'officerdev', + version: '2026.08.28.01', + platform: '>=1.0.0', + + label: 'Fofos', + summary: "The fofos website's content — pages, galleries, drops and collabs", + /** one website's admin, and useless without that website. */ + tags: ['Personal'], + icon: 'PenLine', + color: '#c2410c', + + // Nothing to declare. It talks HTTP to fofos and stores one row; there is no host binary anywhere + // on the path, and fofos itself is a separate deployment rather than something this installs. + + permissions: [ + { + key: 'fofos-admin', + label: 'Fofos', + description: "Edit the fofos website — pages, galleries, drops, collabs and the messages people send", + + // Where the fofos URL and token are set, and the probe that checks them. Prefix-matched, so + // `/_config/test` is covered too — deliberately, because that request carries the token in its + // BODY. A member who could call it would have an oracle pointed at a credential that can + // rewrite the whole public site. + ownerOnly: ['/_config'], + }, + ], +}; diff --git a/web/ConnectionPanel.tsx b/web/ConnectionPanel.tsx new file mode 100644 index 0000000..53cc12f --- /dev/null +++ b/web/ConnectionPanel.tsx @@ -0,0 +1,240 @@ +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.'} +

+ )} +
+ ); +}; diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx new file mode 100644 index 0000000..b2e8d75 --- /dev/null +++ b/web/DetailPanel.tsx @@ -0,0 +1,102 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Link, useSearchParams } from 'react-router'; +import { Settings } from 'lucide-react'; +import { useClient } from 'hooks/useClient'; +import { DEFAULT_SECTION, isSectionKey, sectionFor, type SectionKey } from './sections'; +import { ConnectionPanel } from './ConnectionPanel'; +import { MessagesPanel } from './MessagesPanel'; +import { PlannedSection } from './PlannedSection'; +import { useFofosChanged } from './channel'; + +/** + * The right column: whichever section the nav has selected. + * + * It reads `?section=` from the URL rather than being handed it — the two panels are siblings in a + * workspace and neither owns the other. `?config=1` renders the connection form over the top of + * whatever is selected, so settings are addressable too. + * + * ── The unconfigured check lives HERE, once ── + * + * Every section needs a connection, so every section would otherwise have to handle its absence and + * each would word it slightly differently. Asking `/status` once and rendering the connection form + * instead of any section is one answer for all nine, and it makes "not connected yet" read as a setup + * step rather than as nine broken lists. + */ + +type Status = { configured: boolean; lastProbeOk: boolean | null }; + +/** + * Which component renders which section. + * + * A map rather than a conditional on `built`. The version of this that read + * `built ? : ` was correct only while exactly one section existed — the + * second one to be finished would have rendered Messages under its own name, which is the kind of bug + * that looks like a data problem for an hour. A key with no entry here falls through to Planned, so + * marking a section `built` in sections.ts without writing its panel fails visibly rather than + * silently showing somebody else's. + */ +const PANELS: Partial React.JSX.Element>> = { + messages: MessagesPanel, +}; + +export const DetailPanel = () => { + const [params] = useSearchParams(); + const raw = params.get('section'); + const section: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION; + const showingConfig = params.get('config') === '1'; + + const client = useClient(); + const clientRef = useRef(client); + clientRef.current = client; + + const [configured, setConfigured] = useState(null); + const [signal] = useFofosChanged(); + + const check = useCallback(async () => { + try { + const status = await clientRef.current.get('/fofos-admin/status'); + setConfigured(status.configured); + } catch { + // Cannot tell. Render the section rather than the setup form — a transient failure must not look + // like "you have not set this up", which is a much more alarming thing to be told, and would + // invite somebody to re-enter a connection that was never the problem. + setConfigured(true); + } + }, []); + + useEffect(() => { + void check(); + }, [check, signal]); + + if (configured === null) { + return
Loading…
; + } + + if (showingConfig || !configured) return ; + + const current = sectionFor(section); + + // Everything but `config`, so the settings link keeps the section underneath it. + const configSearch = new URLSearchParams(params); + configSearch.set('config', '1'); + + const Panel = current.built ? PANELS[current.key] : undefined; + + return ( +
+
+

{current.label}

+ {/* A real link, so the settings are addressable and cmd-clickable like anything else. */} + + + Connection + +
+ +
{Panel ? : }
+
+ ); +}; diff --git a/web/MessagesPanel.tsx b/web/MessagesPanel.tsx new file mode 100644 index 0000000..bb7a6f1 --- /dev/null +++ b/web/MessagesPanel.tsx @@ -0,0 +1,143 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Mail, RefreshCw } from 'lucide-react'; +import { useClient } from 'hooks/useClient'; +import { Button } from '@/components/ui/button'; +import { readableError } from './errors'; +import { useFofosChanged } from './channel'; + +/** + * Messages — what people sent through the contact form. + * + * The first section built, and chosen for that on purpose: it is one read-only route, so it proves + * the whole path — Officer panel, through this plugin's proxy, to fofos and back — with nothing else + * in the way to be wrong at the same time. + * + * **Read only, and there is no delete.** fofos's query layer exposes no way to remove a submission, so + * this API cannot offer one. That is the right way round: these are things people sent, and a button + * that destroys one should be a decision somebody makes deliberately rather than a control that + * appeared because a list looked bare without it. + */ + +type Message = { + id: number; + name: string; + email: string; + project_or_company: string | null; + idea: string; + budget_range: string | null; + timeline: string | null; + created_at: string | null; +}; + +/** + * A date somebody can read, in their own locale, without the seconds nobody wants. + * + * `undefined` as the locale so the browser uses the reader's, rather than this file deciding that + * everyone reads dates the way one developer does. + */ +const when = (iso: string | null): string => { + if (!iso) return ''; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ''; + return date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); +}; + +export const MessagesPanel = () => { + 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 [messages, setMessages] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [signal] = useFofosChanged(); + + const load = useCallback(async () => { + setLoading(true); + try { + const response = await clientRef.current.get<{ data: Message[] }>('/fofos-admin/messages'); + setMessages(response.data); + setError(null); + } catch (failure) { + setError(readableError(failure)); + // The previous list is KEPT. A refresh that fails should not blank a screen somebody was + // reading — the error says what happened, and what was there is still there. + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load, signal]); + + if (messages === null && error) { + return ( +
+

{error}

+ +
+ ); + } + + if (messages === null) { + return
Loading…
; + } + + return ( +
+
+

+ {messages.length} message{messages.length === 1 ? '' : 's'} +

+ +
+ + {/* An error ABOVE the list rather than instead of it, once there is something to show. */} + {error &&

{error}

} + + {messages.length === 0 ? ( +
+ +

Nobody has used the contact form yet.

+
+ ) : ( +
    + {messages.map((message) => ( +
  • +
    +

    {message.name}

    + +
    + + {/* A real mailto — replying is the only thing anybody wants to do from here. */} + + {message.email} + + {message.project_or_company && ( + · {message.project_or_company} + )} + + {/* `whitespace-pre-wrap`, because what somebody typed had line breaks in it and they + meant them. */} +

    {message.idea}

    + + {(message.budget_range || message.timeline) && ( +

    + {message.budget_range && Budget: {message.budget_range}} + {message.timeline && Timeline: {message.timeline}} +

    + )} +
  • + ))} +
+ )} +
+ ); +}; diff --git a/web/NavPanel.tsx b/web/NavPanel.tsx new file mode 100644 index 0000000..6515927 --- /dev/null +++ b/web/NavPanel.tsx @@ -0,0 +1,73 @@ +import { Link, useSearchParams } from 'react-router'; +import { DEFAULT_SECTION, SECTIONS, isSectionKey, type SectionKey } from './sections'; + +/** + * The left column: which part of the fofos site the detail panel is showing. + * + * ── Selection is in the URL ── + * + * `?section=…`, per `docs/navigation-audit.md`: what is on screen is addressable, so it survives a + * reload, can be linked, and the detail panel reads it directly. The two panels are siblings in a + * workspace and never speak to each other. + * + * ── Real links, not buttons ── + * + * `AGENTS.md`: "Rows and nav items are real links." `plugins/proxy-manager/web/NavPanel.tsx` uses + * buttons calling `setParams`, which keeps the URL correct but gives up anchor semantics — no + * cmd-click into a new tab, no middle-click, not link-focusable. That is the older pattern and the + * file that documents it says not to copy it. + * + * `` rather than `` because active state here is a QUERY parameter and NavLink's + * `isActive` compares paths — every section would light up at once. So the active class is computed + * from the same value the detail panel reads, which is the one place it can come from. + */ + +const rowClass = (active: boolean) => + `flex w-full items-start gap-2.5 border-b border-border px-3 py-2.5 text-left transition-colors ${ + active ? 'bg-muted' : 'hover:bg-muted/50' + }`; + +export const NavPanel = () => { + const [params] = useSearchParams(); + const raw = params.get('section'); + const active: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION; + + return ( + + ); +}; diff --git a/web/PlannedSection.tsx b/web/PlannedSection.tsx new file mode 100644 index 0000000..5ec038e --- /dev/null +++ b/web/PlannedSection.tsx @@ -0,0 +1,24 @@ +import { Hammer } from 'lucide-react'; +import type { Section } from './sections'; + +/** + * A section that has a place in the nav and no panel yet. + * + * It says what it WILL do rather than apologising. A screen reading "coming soon" tells somebody they + * were right to look and wrong to expect anything; naming the thing at least confirms they are in the + * right place, and that the section is planned rather than broken. + * + * The htmx admin at `admin.fofos.com` still edits every one of these, so nothing here is unreachable + * work — it is work that has not moved yet. Saying so is the difference between "not built" and + * "not possible". + */ +export const PlannedSection = ({ section }: { section: Section }) => ( +
+ +

{section.label} is not built here yet.

+

{section.hint}.

+

+ Until it moves, this section is still editable in the old admin. +

+
+); diff --git a/web/channel.ts b/web/channel.ts new file mode 100644 index 0000000..8ad257c --- /dev/null +++ b/web/channel.ts @@ -0,0 +1,36 @@ +import { useCallback, useRef } from 'react'; +import { defineChannel } from 'hooks/usePanelChannel'; + +/** + * "Something on the fofos site changed — anyone reading it should read it again." + * + * ── Why a channel and not the URL ── + * + * `docs/navigation-audit.md` draws the line: anything ADDRESSABLE goes in the URL, and a channel is + * only for a signal. Nobody wants to link to "the moment after the connection was saved", and there is + * nothing to select — this is the second kind. + * + * ── The nonce is a plain counter, and that is not an accident ── + * + * Copied deliberately from `plugins/proxy-manager/web/channel.ts`, which records why. + * `setSignal(n => n + 1)` is WRONG: the functional setter applies against the value captured at + * render, so two bumps inside one render window both compute `snapshot + 1` and the second silently + * overwrites the first — nobody re-reads. `Date.now()` has the same flaw in miniature, two bumps in + * one millisecond being one number. A module counter never reads React state, so it cannot be wrong + * however often it is called. + */ +const useFofosChangedChannel = defineChannel('fofos-admin:changed', 0); + +let nonce = 0; + +export const useFofosChanged = () => { + const [signal, setSignal] = useFofosChangedChannel(); + + // The setter is a fresh closure every render and this `bump` goes into dependency lists, so it is + // read through a ref — stable identity without freezing the first render's setter. + const setRef = useRef(setSignal); + setRef.current = setSignal; + const bump = useCallback(() => setRef.current(++nonce), []); + + return [signal, bump] as const; +}; diff --git a/web/errors.ts b/web/errors.ts new file mode 100644 index 0000000..410edac --- /dev/null +++ b/web/errors.ts @@ -0,0 +1,31 @@ +/** + * Turning a failed request into a sentence somebody can act on. + * + * The three that matter are genuinely different problems and send you to different places: nothing is + * configured yet, fofos cannot be reached, or fofos refused the token. A panel that says "something + * went wrong" for all three is the reason people restart servers that were fine. + */ + +export type ApiFailure = { + error?: string; + /** No connection stored. A setup step, not a fault. */ + unconfigured?: boolean; + /** fofos never answered — down, wrong URL, no route to it. */ + unreachable?: boolean; +}; + +/** What the client throws carries the parsed body on `.body` when there was one. */ +export const failureOf = (error: unknown): ApiFailure => { + const body = (error as { body?: unknown })?.body; + if (body && typeof body === 'object') return body as ApiFailure; + return { error: error instanceof Error ? error.message : String(error) }; +}; + +export const readableError = (error: unknown): string => { + const failure = failureOf(error); + + if (failure.unconfigured) return 'No fofos connection is set up yet.'; + if (failure.unreachable) return failure.error ?? 'fofos did not answer.'; + + return failure.error ?? 'That did not work, and the server did not say why.'; +}; diff --git a/web/layout.ts b/web/layout.ts new file mode 100644 index 0000000..792b5be --- /dev/null +++ b/web/layout.ts @@ -0,0 +1,21 @@ +import type { LayoutNode } from 'officerdev'; + +/** + * A nav down the left, whatever it selects beside it. + * + * Two panels rather than three: `plugins/proxy-manager` puts a chat across the bottom, and this does + * not, because there is no fofos skill for an agent to use yet. A chat panel with nothing to tell the + * agent about the site would be a third of the screen spent on a general-purpose conversation that is + * better had in the chat screen. Worth adding on the day this plugin ships a `skills/` directory. + * + * The nav is the narrow one: nine rows of two short lines, and nothing in it grows. + */ +export const defaultLayout: LayoutNode = { + type: 'group', + id: 'fofos-admin-root', + direction: 'horizontal', + children: [ + { node: { type: 'panel', id: 'fofos-admin-nav', appType: 'fofos-admin-nav' }, size: 28 }, + { node: { type: 'panel', id: 'fofos-admin-detail', appType: 'fofos-admin-detail' }, size: 72 }, + ], +}; diff --git a/web/panels.ts b/web/panels.ts new file mode 100644 index 0000000..4a5a23b --- /dev/null +++ b/web/panels.ts @@ -0,0 +1,31 @@ +import type { AppRegistryMeta } from 'officerdev'; +import { List, PanelRight } from 'lucide-react'; +import { NavPanel } from './NavPanel'; +import { DetailPanel } from './DetailPanel'; + +/** + * Two panels: a nav, and whatever it selects. + * + * They are SIBLINGS that never speak to each other — the nav writes `?section=` and the detail panel + * reads it. That is `docs/navigation-audit.md`'s rule, and it is what makes every section linkable and + * survive a reload. + * + * `availableOnPanel: false` — these belong to this plugin's screen, not to the generic panel picker. + * A "Fofos sections" panel offered on somebody's dashboard would render a nav with nothing beside it. + */ +export const appRegistryMetas: AppRegistryMeta[] = [ + { + key: 'fofos-admin-nav', + name: 'Sections', + icon: List, + component: NavPanel, + availableOnPanel: false, + }, + { + key: 'fofos-admin-detail', + name: 'Detail', + icon: PanelRight, + component: DetailPanel, + availableOnPanel: false, + }, +]; diff --git a/web/sections.ts b/web/sections.ts new file mode 100644 index 0000000..90f2cec --- /dev/null +++ b/web/sections.ts @@ -0,0 +1,126 @@ +import type { LucideIcon } from 'lucide-react'; +import { Home, Info, Layers, Images, Handshake, Sparkles, Wrench, Settings, Mail } from 'lucide-react'; + +/** + * The sections of the fofos admin, in nav order. + * + * ── The order is the client's site, not the database's ── + * + * It follows the sitemap: the pages someone edits most, first. Site settings and Messages sit at the + * bottom because they are not pages — one is configuration and the other is an inbox. + * + * ── `built` ── + * + * The panels arrive one section at a time, each verified against the live site before the next is + * started. A section that is not built yet says so, plainly, with what it will do — which is a better + * answer than a nav that hides most of itself and gives no sense of what is coming. + * + * It mirrors `built` in fofos's own `src/apps/web/site-map.ts`, and for the same reason: one list, so + * the nav and what actually renders cannot disagree. + */ + +export type SectionKey = + | 'messages' + | 'site' + | 'about' + | 'home' + | 'gallery' + | 'drops' + | 'collabs' + | 'collections' + | 'services'; + +export type Section = { + key: SectionKey; + label: string; + icon: LucideIcon; + /** One line under the label in the nav — what this section is, for somebody who has not opened it. */ + hint: string; + /** The fofos path this reads, relative to the plugin's mount. Null when nothing is built. */ + path: string | null; + built: boolean; +}; + +export const SECTIONS: Section[] = [ + { + key: 'home', + label: 'Home', + icon: Home, + hint: 'The hero, the intro, and the gallery', + path: '/fofos-admin/home', + built: false, + }, + { + key: 'about', + label: 'About Us', + icon: Info, + hint: 'The header photograph and the copy', + path: '/fofos-admin/about', + built: false, + }, + { + key: 'collections', + label: 'Collections', + icon: Layers, + hint: 'Collections, and the items in them', + path: '/fofos-admin/collections', + built: false, + }, + { + key: 'gallery', + label: 'Gallery', + icon: Images, + hint: 'Standalone pieces', + path: '/fofos-admin/gallery', + built: false, + }, + { + key: 'collabs', + label: 'Collabs', + icon: Handshake, + hint: 'Projects with other people', + path: '/fofos-admin/collabs', + built: false, + }, + { + key: 'drops', + label: 'Drops', + icon: Sparkles, + hint: 'Releases', + path: '/fofos-admin/drops', + built: false, + }, + { + key: 'services', + label: 'Services', + icon: Wrench, + hint: 'A fixed tree — the words, not the shape', + path: '/fofos-admin/services', + built: false, + }, + { + key: 'site', + label: 'Site settings', + icon: Settings, + hint: 'Partners, social links, contact details', + path: '/fofos-admin/site', + built: false, + }, + { + key: 'messages', + label: 'Messages', + icon: Mail, + hint: 'What people sent through the contact form', + path: '/fofos-admin/messages', + built: true, + }, +]; + +/** Where the detail panel goes when the URL says nothing, or says something it does not recognise. */ +export const DEFAULT_SECTION: SectionKey = 'messages'; + +export const isSectionKey = (value: string | null): value is SectionKey => + SECTIONS.some((section) => section.key === value); + +export const sectionFor = (key: SectionKey): Section => + SECTIONS.find((section) => section.key === key) ?? SECTIONS[SECTIONS.length - 1]!;