the token lives in tea's config, once, per user
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.
This commit is contained in:
+50
-2
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { CheckCircle2, Loader2, TriangleAlert } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -29,12 +29,16 @@ export type ConnectionState = {
|
||||
* 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 { put } = useClient();
|
||||
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
|
||||
@@ -43,6 +47,21 @@ export const GiteaConnection = ({ state, onSaved }: Props) => {
|
||||
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);
|
||||
@@ -94,6 +113,29 @@ export const GiteaConnection = ({ state, onSaved }: Props) => {
|
||||
/>
|
||||
</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">
|
||||
@@ -101,6 +143,12 @@ export const GiteaConnection = ({ state, onSaved }: Props) => {
|
||||
{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}>
|
||||
|
||||
Reference in New Issue
Block a user