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(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
, 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.

{state.unreadable ? 'Re-enter your token' : 'Connect Gitea'}

{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.'}

{needsUrl && ( )} {/* 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 : ( )} {/* 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 ? (

tea is configured{state.tea.user ? ` as ${state.tea.user}` : ''} — leave the field blank to keep it

) : null} {state.canStoreToken === false ? (

This account has no system user yet, so a token cannot be stored for the command line. The web view above works regardless.

) : null} {tested ? (

{tested.ok ? `Authenticated as ${tested.user}` : tested.error}

) : null} {error &&

{error}

}
{/* Only offered when there is something to test. */} {state.tea?.hasToken ? ( ) : null} {/* Only once there is something to go back TO. */} {state.configured && !state.unreadable ? ( ) : null}
); };