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.
180 lines
8.2 KiB
TypeScript
180 lines
8.2 KiB
TypeScript
import { useState } from 'react';
|
|
import { useNavigate } from 'react-router';
|
|
import { CheckCircle2, Loader2, TriangleAlert } from 'lucide-react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
|
|
// Where the instance and the token are set.
|
|
//
|
|
// Two halves, and which one you get depends on who you are. The URL is the OWNER'S — one instance per
|
|
// server — and everyone else supplies only a token, inheriting the owner's URL. That is not a UI choice:
|
|
// `service_connections` stores a member's row with a null URL for exactly this reason, so that "members
|
|
// never see the instance URL" is a property of the schema rather than a filter somebody has to remember.
|
|
//
|
|
// A blank token on save means "keep the stored one", so the URL can be corrected without re-pasting a
|
|
// token. There is no way to read one back out.
|
|
|
|
export type ConnectionState = {
|
|
configured: boolean;
|
|
url?: string | null;
|
|
user?: string | null;
|
|
isOwner?: boolean;
|
|
/** Whether the OWNER has set the instance URL. Members inherit it and never set one. */
|
|
instanceConfigured?: boolean;
|
|
/**
|
|
* A token is stored but cannot be decrypted.
|
|
*
|
|
* The platform's encryption key is minted on first use and not backed up, so losing it re-mints one
|
|
* and every previously encrypted column becomes unreadable. Reported separately from "not configured"
|
|
* because the person needs telling that nothing is broken and their URL is still there — only the
|
|
* token has to be pasted again.
|
|
*/
|
|
unreadable?: boolean;
|
|
/** What `tea` already has for this instance. Null when it has no login for it. */
|
|
tea?: { name: string; url?: string; user: string | null; hasToken: boolean } | null;
|
|
/** False when this account has no OS user, so there is nowhere to write a config. */
|
|
canStoreToken?: boolean;
|
|
};
|
|
|
|
type Props = { state: ConnectionState; onSaved: () => void };
|
|
|
|
export const GiteaConnection = ({ state, onSaved }: Props) => {
|
|
const { get, put } = useClient();
|
|
const navigate = useNavigate();
|
|
// Loopback is the right default and worth putting in front of someone: the sidecar fetches
|
|
// SERVER-SIDE, so `localhost` means the machine Officer runs on. It needs no TLS and never leaves the
|
|
// box — where the public URL would take the same traffic out and back.
|
|
// Seeded from what tea already knows before falling back to a guess. On a machine where somebody ran
|
|
// `tea login` by hand, the URL is already on disk and asking for it again is asking for something the
|
|
// box can see.
|
|
const [url, setUrl] = useState(state.url ?? state.tea?.url ?? 'http://localhost:3000');
|
|
const [token, setToken] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [tested, setTested] = useState<{ ok: boolean; user?: string; error?: string } | null>(null);
|
|
|
|
// Runs `tea whoami` AS this account. It proves the token the AGENT will use, not one the platform read
|
|
// out of a database — which is the only test worth having, since those are now the same thing.
|
|
const test = async () => {
|
|
setBusy(true);
|
|
setTested(null);
|
|
try {
|
|
setTested(await get<{ ok: boolean; user?: string; error?: string }>('/gitea/_test'));
|
|
} catch (err) {
|
|
setTested({ ok: false, error: err instanceof Error ? err.message : 'could not test' });
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
// ONE THING AT A TIME. Until the platform knows where Gitea is, a token cannot be checked — there is
|
|
// nothing to check it against — and once the URL is known, tea may already hold a working token, in
|
|
// which case the second step never appears at all.
|
|
//
|
|
// Members skip step one entirely: the instance is the owner's.
|
|
const needsUrl = state.isOwner !== false && !state.instanceConfigured;
|
|
|
|
const save = async (ev?: React.FormEvent) => {
|
|
ev?.preventDefault();
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
await put('/gitea/_config', state.isOwner === false ? { token } : { url, token });
|
|
onSaved();
|
|
// Back to the frame. Saving from `/gitea/connection` otherwise leaves you looking at the form you
|
|
// just submitted, which reads as nothing having happened.
|
|
navigate('/gitea');
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'could not save');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
// 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">
|
|
{state.unreadable
|
|
? "The stored token can no longer be read — this server's encryption key changed. Nothing else is lost; paste it again."
|
|
: needsUrl
|
|
? 'Where Gitea runs. Officer reaches it from the server, so localhost means the machine Officer runs on.'
|
|
: state.isOwner === false
|
|
? 'Your own access token. The instance is already set.'
|
|
: 'Your own access token for this instance.'}
|
|
</p>
|
|
</div>
|
|
|
|
{needsUrl && (
|
|
<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" />
|
|
</label>
|
|
)}
|
|
|
|
{/* Not shown while the instance is still unknown: a token cannot be validated without one, and
|
|
tea may already hold one for whatever URL is entered above. */}
|
|
{needsUrl ? null : (
|
|
<label className="space-y-1.5">
|
|
<span className="text-xs font-medium text-muted-foreground">Access token</span>
|
|
<Input
|
|
type="password"
|
|
value={token}
|
|
onChange={(ev) => setToken(ev.target.value)}
|
|
placeholder={state.tea?.hasToken && !state.unreadable ? '•••••••• (unchanged)' : 'Settings → Applications in Gitea'}
|
|
/>
|
|
</label>
|
|
)}
|
|
|
|
{/* What tea already has, read from the account's own config. On a machine where someone ran
|
|
`tea login` by hand, this says so instead of demanding a token they already gave. */}
|
|
{state.tea?.hasToken ? (
|
|
<p className="flex items-center gap-1.5 text-xs text-success">
|
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
|
tea is configured{state.tea.user ? ` as ${state.tea.user}` : ''} — leave the field blank to keep it
|
|
</p>
|
|
) : null}
|
|
|
|
{state.canStoreToken === false ? (
|
|
<p className="flex items-start gap-1.5 text-xs text-warning">
|
|
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
This account has no system user yet, so a token cannot be stored for the command line. The web
|
|
view above works regardless.
|
|
</p>
|
|
) : null}
|
|
|
|
{tested ? (
|
|
<p className={`text-xs ${tested.ok ? 'text-success' : 'text-destructive'}`}>
|
|
{tested.ok ? `Authenticated as ${tested.user}` : tested.error}
|
|
</p>
|
|
) : null}
|
|
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
|
|
<div className="flex gap-2">
|
|
<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 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 type="button" variant="outline" onClick={() => navigate('/gitea')} disabled={busy}>
|
|
Cancel
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</form>
|
|
);
|
|
};
|