Files
fofos-admin/web/DatabaseBlock.tsx
T
pastilhasandClaude Opus 5 a1fe543dff two database names, and a button that creates what is missing
Production and development, stored as NAMES rather than URLs. The site runs on
the same Postgres Officer is already connected to — that is the whole reason a
name is enough — and a URL here would be a second copy of a host, a port and a
credential that already exist in the platform's configuration, free to drift
from it and free to point at another machine.

Naming and creating are separate, and were worth two buttons. A database that
already exists still needs its name recorded, and making Create the only way to
save would leave nobody able to point the site at something already set up. The
tick beside each field comes from pg_database, not from what was typed — the
useful question is whether the thing exists, and that came apart from "somebody
saved a name" the moment the two actions split.

CREATE DATABASE cannot be parameterised, so the name goes into the statement as
text and the identifier check is the entire security boundary. It is therefore
strict rather than accommodating — lowercase, leading letter or underscore,
nothing else — and it is re-run immediately before the statement is built,
because the gap between storing a name and using it is exactly where a check
that happened earlier stops being a check. Postgres folds unquoted identifiers
to lowercase, so accepting `Fofos` would create `fofos` and then report a name
nobody could find; refusing is kinder than silently changing it.

Officer's own database and the Postgres templates are refused outright. Pointing
the site at Officer's database would not read as a configuration mistake — it
would read as the platform corrupting itself.

Nothing here drops a database, and nothing should: `fofos_website` and
`fofos_old` are one keystroke apart and one of them holds the client's site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 12:06:40 +01:00

186 lines
6.7 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { Check, CircleCheck, CircleDashed, Database, Loader2, Plus } 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 { Block } from './Block';
import { useFlash } from './useFlash';
/**
* Where the fofos site's own data lives — two databases, named here.
*
* ── Naming and creating are separate ──
*
* Save stores what you typed; Create makes whichever of the two is not there yet. They are different
* decisions and were worth two buttons: a database that already exists still needs its name recorded,
* and making Create the only way to save would mean nobody could point the site at something that was
* already set up.
*
* ── It reports what is THERE, per name ──
*
* A tick or a dashed circle beside each field, from `pg_database` rather than from what was saved. The
* useful question about a database name is not whether somebody typed it — it is whether the thing it
* names exists, and those two came apart the moment saving stopped creating.
*
* ── Create only offers itself when there is something to create ──
*
* Both present and the button goes. A button that does nothing is a thing to wonder about later.
*/
type Named = { name: string | null; exists: boolean | null };
type Status = { production: Named; development: Named; available: string[] };
export const DatabaseBlock = () => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [status, setStatus] = useState<Status | null>(null);
const [production, setProduction] = useState('');
const [development, setDevelopment] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [flashing, flash] = useFlash();
const load = useCallback(async () => {
try {
const current = await clientRef.current.get<Status>('/fofos-admin/_config/databases');
setStatus(current);
// Only seed the fields from the server when they are untouched, so a reload landing mid-typing
// cannot replace what somebody is in the middle of writing.
setProduction((typed) => typed || (current.production.name ?? ''));
setDevelopment((typed) => typed || (current.development.name ?? ''));
} catch {
setStatus(null);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const save = async () => {
setBusy(true);
setError(null);
try {
setStatus(
await clientRef.current.put<Status>('/fofos-admin/_config/databases', { production, development }),
);
flash();
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(false);
}
};
const create = async () => {
setBusy(true);
setError(null);
try {
const result = await clientRef.current.post<{ created: string[]; status: Status }>(
'/fofos-admin/_config/databases/create',
{},
);
setStatus(result.status);
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(false);
}
};
const field = (
label: string,
hint: string,
value: string,
onChange: (next: string) => void,
state: Named | undefined,
) => {
// Only about what is SAVED. A tick next to a name somebody is still typing would be about a
// different database from the one on screen.
const settled = state?.name === value.trim() ? state : undefined;
const Icon = settled?.exists ? CircleCheck : CircleDashed;
return (
<div className="space-y-1">
<div className="flex items-baseline gap-2">
<label className="text-xs font-medium">{label}</label>
<span className="text-xs text-muted-foreground/70">{hint}</span>
</div>
<div className="flex items-center gap-2">
<Input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={label === 'Production' ? 'fofos_website' : 'fofos_website_dev'}
className="w-full font-mono text-xs"
autoComplete="off"
spellCheck={false}
/>
<span className="flex w-24 shrink-0 items-center gap-1 text-xs text-muted-foreground">
{settled ? (
<>
<Icon className={`size-3.5 ${settled.exists ? 'text-success' : ''}`} />
{settled.exists ? 'exists' : 'not yet'}
</>
) : null}
</span>
</div>
</div>
);
};
const dirty =
production.trim() !== (status?.production.name ?? '') || development.trim() !== (status?.development.name ?? '');
// From the SAVED names, not the typed ones — Create acts on what is stored.
const missing = [status?.production, status?.development].filter((entry) => entry?.name && entry.exists === false);
return (
<Block
icon={Database}
title="Database"
hint="Where the fofos site's own data lives — on Officer's own Postgres."
status={
flashing ? (
<span className="flex items-center gap-1 text-xs text-success">
<Check className="size-3.5" />
Saved
</span>
) : undefined
}
>
{!status ? null : (
<>
{field('Production', 'what the live site reads', production, setProduction, status.production)}
{field('Development', 'what you work against', development, setDevelopment, status.development)}
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-3">
<Button size="sm" onClick={() => void save()} disabled={busy || !dirty}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
Save
</Button>
{/* Only when a saved name has nothing behind it. Nothing to create, no button. */}
{missing.length > 0 && (
<Button variant="outline" size="sm" onClick={() => void create()} disabled={busy || dirty}>
<Plus className="size-3.5" />
Create {missing.length === 1 ? missing[0]!.name : 'both'}
</Button>
)}
{/* Save first. Create acts on what is stored, and a button that quietly used a different
value from the one in the field would be the worst kind of surprise here. */}
{missing.length > 0 && dirty && (
<span className="text-xs text-muted-foreground/70">Save the names first.</span>
)}
</div>
</>
)}
</Block>
);
};