Files
gitea/web/GiteaConnection.tsx
T
officer c31f95155f make the connection settings reachable at /gitea/connection
The form appeared only when nothing was configured, so once a token was saved
there was no way back to it — a member who mistyped theirs, or anyone rotating
one, had nowhere to go. On a single-user machine that is an annoyance; on a
company one it is the ordinary case, because every member has a token of their
own to set.

Uses the route pair the platform already generates for every plugin, so this
needed no platform change. A plug icon beside the open-in-new-tab button, as a
real Link rather than a button, because it is navigation and should be
cmd-clickable.

Saving returns to the frame — otherwise you are left looking at the form you
just submitted, which reads as nothing having happened.

The two halves were already right: the panel shows only the token field when
isOwner is false, and getResolvedServiceCredentials gives a member the owner's
URL with their OWN secret, never the owner's.
2026-08-19 09:58:30 +00:00

114 lines
4.8 KiB
TypeScript

import { useState } from 'react';
import { useNavigate } from 'react-router';
import { Loader2 } 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;
};
type Props = { state: ConnectionState; onSaved: () => void };
export const GiteaConnection = ({ state, onSaved }: Props) => {
const { 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 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>
{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 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>
);
};