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>
This commit is contained in:
+180
-10
@@ -1,15 +1,185 @@
|
||||
import { Database } from 'lucide-react';
|
||||
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.
|
||||
* Where the fofos site's own data lives — two databases, named here.
|
||||
*
|
||||
* 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 inventing fields to fill the space would be deciding it in the wrong place.
|
||||
* ── 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.
|
||||
*/
|
||||
export const DatabaseBlock = () => (
|
||||
<Block icon={Database} title="Database" hint="Where the fofos site's own data lives.">
|
||||
<p className="text-xs text-muted-foreground/70">Nothing to set yet.</p>
|
||||
</Block>
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user