It was stored encrypted in service_connections as well. Nothing needs it there any more: the UI is an iframe carrying the browser's own Gitea session, so the only consumer left was the health probe. Meanwhile tea keeps its own copy regardless — it is what the agent uses, and what this plugin's affordance tells the agent to use. Two copies of one secret is a drift waiting to happen: rotate with `tea login` and the database quietly keeps the dead one. So the token lives once, where it is used, and saving now writes `tea login add --name officer` AS that account — a member's token lands in a home only they can read. `secret: null` on save clears whatever an earlier version stored, so an upgraded machine stops carrying a copy nothing reads. service_connections keeps the URL, which is the owner's and genuinely shared. The settings page READS the existing config first, so on a machine where the owner and their members already ran `tea login` by hand it says "tea is configured as X" rather than demanding something they have. It also says when an account has no OS user yet, since there is then nowhere to write one — the web view works regardless. Test runs `tea whoami` as that account. Deliberately not a call the platform makes with a token it read: the point is to prove what the AGENT will get, and those are now the same thing. `--name officer` so the platform never overwrites a login somebody set up by hand. Noted rather than hidden: `tea login add` takes the token in argv, visible in ps while it runs. tea offers no stdin form, and the alternative is writing its YAML by hand — a visible argument traded for a fragile format.
162 lines
6.9 KiB
TypeScript
162 lines
6.9 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;
|
|
/**
|
|
* 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; 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.
|
|
const [url, setUrl] = useState(state.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);
|
|
}
|
|
};
|
|
|
|
const save = async () => {
|
|
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 (
|
|
<div 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."
|
|
: state.isOwner === false
|
|
? 'Your own access token. The instance is already set.'
|
|
: 'Where Gitea runs, and a token for it.'}
|
|
</p>
|
|
</div>
|
|
|
|
{state.isOwner !== false && (
|
|
<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>
|
|
)}
|
|
|
|
<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'}
|
|
/>
|
|
</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 onClick={save} disabled={busy || ((!state.configured || state.unreadable) && !token)}>
|
|
{busy ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
|
|
{state.configured ? 'Save' : 'Connect'}
|
|
</Button>
|
|
{/* Only offered when there is something to test. */}
|
|
{state.tea?.hasToken ? (
|
|
<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}>
|
|
Cancel
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|