ask for the URL first, and offer what tea already knows

The form asked for a URL and a token together, on a fresh instance, while the
machine in front of it often had both on disk already — somebody had run `tea
login` by hand.

Two changes.

One thing at a time: until the platform knows where Gitea is, a token cannot be
checked, because there is nothing to check it against. So the owner names the
instance first, and only then does the page ask for — or discover — a token.
That step now probes /api/v1/version unauthenticated, since demanding a token
to validate a URL refuses a good URL for the wrong reason and sends whoever
typed it looking for a credential problem.

And it reads tea's own config when the platform has no instance yet, offering
the URL it finds. A machine that already has a working login should not be
asked to retype what it can see.

Members skip the first step entirely — the instance is the owner's.
This commit is contained in:
officer
2026-08-19 10:25:22 +00:00
parent 862512d83a
commit d4efc52447
4 changed files with 102 additions and 23 deletions
+30 -9
View File
@@ -20,6 +20,8 @@ export type ConnectionState = {
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.
*
@@ -30,7 +32,7 @@ export type ConnectionState = {
*/
unreadable?: boolean;
/** What `tea` already has for this instance. Null when it has no login for it. */
tea?: { name: string; user: string | null; hasToken: boolean } | null;
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;
};
@@ -43,7 +45,10 @@ export const GiteaConnection = ({ state, onSaved }: Props) => {
// 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.
const [url, setUrl] = useState(state.url ?? 'http://localhost:3000');
// 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);
@@ -63,6 +68,13 @@ export const GiteaConnection = ({ state, onSaved }: Props) => {
}
};
// 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 () => {
setBusy(true);
setError(null);
@@ -86,13 +98,15 @@ export const GiteaConnection = ({ state, onSaved }: Props) => {
<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."
: state.isOwner === false
? 'Your own access token. The instance is already set.'
: 'Where Gitea runs, and a token for it.'}
: 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>
{state.isOwner !== false && (
{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" />
@@ -103,15 +117,19 @@ export const GiteaConnection = ({ state, onSaved }: Props) => {
</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.configured && !state.unreadable ? '•••••••• (unchanged)' : 'Settings → Applications in Gitea'}
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. */}
@@ -139,9 +157,12 @@ 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 || ((!state.configured || state.unreadable) && !token)}>
<Button
onClick={save}
disabled={busy || (needsUrl ? !url.trim() : !state.tea?.hasToken && !token)}
>
{busy ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
{state.configured ? 'Save' : 'Connect'}
{needsUrl ? 'Continue' : state.configured ? 'Save' : 'Connect'}
</Button>
{/* Only offered when there is something to test. */}
{state.tea?.hasToken ? (