a real form, and the URL step no longer demands a token

Two things reported from a production instance.

The URL step was refused with "an access token is required the first time" — a
check that predates splitting the screen into two steps, and which made the
first step impossible to complete. Naming the instance is now its own step; a
member still needs a token, since setting one is all they do.

And the fields were a div, so pressing Enter did nothing and the button was the
only way to submit. That is a keyboard trap wearing a layout. Now a <form> with
onSubmit, with the other two buttons marked type="button" so they do not submit
it.

19 panel files across the platform have the same problem. Recorded in the
platform's TODO.md rather than swept blindly — a search box has no submit, and
a multi-field editor wants the useForm hook.

Also dropped the hint under the URL field, which repeated the sentence directly
above it.
This commit is contained in:
officer
2026-08-19 10:32:59 +00:00
parent d4efc52447
commit 8125363e0b
2 changed files with 18 additions and 14 deletions
+8 -1
View File
@@ -198,7 +198,14 @@ async function handleConfig(req: Request, userId: number): Promise<Response> {
}
const existing = await getServiceConnection(userId, 'gitea');
if (!token && !existing?.hasSecret) return bad('an access token is required the first time');
// NOT required on the first save any more. Naming the instance is its own step — the owner says
// where Gitea is, and the token is asked for (or found in tea's config) afterwards. This check
// predates that split and refused the URL step outright.
//
// A member is different: they set nothing but a token, so an empty save from them means nothing.
if (!owner && !token && !(await readTeaLogin(userId, base))?.hasToken) {
return bad('an access token is required');
}
// Probe with the credentials as they WILL be, so a save either stores something that works or
// fails with the reason. Storing first and discovering later makes every downstream screen fail
+10 -13
View File
@@ -75,7 +75,8 @@ export const GiteaConnection = ({ state, onSaved }: Props) => {
// Members skip step one entirely: the instance is the owner's.
const needsUrl = state.isOwner !== false && !state.instanceConfigured;
const save = async () => {
const save = async (ev?: React.FormEvent) => {
ev?.preventDefault();
setBusy(true);
setError(null);
try {
@@ -92,7 +93,10 @@ export const GiteaConnection = ({ state, onSaved }: Props) => {
};
return (
<div className="mx-auto flex h-full max-w-md flex-col justify-center gap-4 p-6">
// A real <form>, so Enter submits. Typing a URL and pressing return is what anyone does, and a div
// full of inputs silently swallows it — the button becomes the only way in, which is a keyboard trap
// wearing a layout.
<form onSubmit={save} className="mx-auto flex h-full max-w-md flex-col justify-center gap-4 p-6">
<div>
<h2 className="text-base font-semibold">{state.unreadable ? 'Re-enter your token' : 'Connect Gitea'}</h2>
<p className="mt-1 text-sm text-muted-foreground">
@@ -110,10 +114,6 @@ export const GiteaConnection = ({ state, onSaved }: Props) => {
<label className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">Instance URL</span>
<Input value={url} onChange={(ev) => setUrl(ev.target.value)} placeholder="http://localhost:3000" />
<span className="block text-xs text-muted-foreground">
Officer reaches this from the server, not from your browser — so localhost means the machine
Officer runs on.
</span>
</label>
)}
@@ -157,26 +157,23 @@ export const GiteaConnection = ({ state, onSaved }: Props) => {
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex gap-2">
<Button
onClick={save}
disabled={busy || (needsUrl ? !url.trim() : !state.tea?.hasToken && !token)}
>
<Button type="submit" disabled={busy || (needsUrl ? !url.trim() : !state.tea?.hasToken && !token)}>
{busy ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
{needsUrl ? 'Continue' : state.configured ? 'Save' : 'Connect'}
</Button>
{/* Only offered when there is something to test. */}
{state.tea?.hasToken ? (
<Button variant="outline" onClick={test} disabled={busy}>
<Button type="button" variant="outline" onClick={test} disabled={busy}>
Test
</Button>
) : null}
{/* Only once there is something to go back TO. */}
{state.configured && !state.unreadable ? (
<Button variant="outline" onClick={() => navigate('/gitea')} disabled={busy}>
<Button type="button" variant="outline" onClick={() => navigate('/gitea')} disabled={busy}>
Cancel
</Button>
) : null}
</div>
</div>
</form>
);
};