Saving the connection showed an error that a refresh then cleared. The effect that re-mints the frame session re-ran, but `state` was left at 'ready' from the previous pass — so the iframe mounted immediately, before the cookie existed, and the first request inside it answered 401. By the time anyone refreshed, the mint had finished. A stale 'ready' is worse than a spinner: it renders something broken confidently. Also stays on the form after the URL-only step, where there is still a token to set. Bouncing to a frame that immediately sends you back is worse than not moving.
186 lines
8.6 KiB
TypeScript
186 lines
8.6 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 });
|
|
// ORDER MATTERS. `onSaved` re-reads the connection and re-mints the frame session; navigating
|
|
// first would swap to the frame while that is still in flight, and the frame would load against a
|
|
// session that does not exist yet.
|
|
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.
|
|
//
|
|
// Only when there is a frame to go back TO: after the URL-only step there is still a token to
|
|
// set, and bouncing to a frame that immediately sends you back is worse than staying put.
|
|
if (!needsUrl) 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>
|
|
);
|
|
};
|