a way to actually connect it
Deleting the old UI took its connection panel with it, so the plugin installed, opened, and had nowhere to type a URL. The frame would have loaded and answered 503 on every request inside it — which reads as Gitea being down rather than as nobody having said where Gitea is. The panel now asks for the connection FIRST and shows the form when there is none. 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 and inherits it. Not a UI choice — service_connections stores a member's row with a null URL for exactly this reason, so "members never see the instance URL" is a property of the schema rather than a filter somebody has to remember. Defaults to http://localhost:3000 and says why in the field: 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 takes the same traffic out and back.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { useState } from 'react';
|
||||
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;
|
||||
};
|
||||
|
||||
type Props = { state: ConnectionState; onSaved: () => void };
|
||||
|
||||
export const GiteaConnection = ({ state, onSaved }: Props) => {
|
||||
const { put } = useClient();
|
||||
// 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();
|
||||
} 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">Connect Gitea</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{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 ? '•••••••• (unchanged)' : 'Settings → Applications in Gitea'}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
<Button onClick={save} disabled={busy || (!state.configured && !token)}>
|
||||
{busy ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
{state.configured ? 'Save' : 'Connect'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+20
-4
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { ExternalLink, Loader2, TriangleAlert } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { GiteaConnection, type ConnectionState } from './GiteaConnection';
|
||||
|
||||
// Gitea's own web interface, rendered in place.
|
||||
//
|
||||
@@ -24,16 +25,27 @@ import { Button } from '@/components/ui/button';
|
||||
const FRAME_SRC = '/api/gitea/_web/';
|
||||
|
||||
export const GiteaFrame = () => {
|
||||
const { post } = useClient();
|
||||
const { get, post } = useClient();
|
||||
const [state, setState] = useState<'opening' | 'ready' | 'failed'>('opening');
|
||||
const [connection, setConnection] = useState<ConnectionState | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// Not in a dependency array: `useClient()` returns a new object every render, so depending on `post`
|
||||
// would re-mint on every keystroke elsewhere in the app. Mount-only is what is meant.
|
||||
// Not in a dependency array: `useClient()` returns a new object every render, so depending on `get`
|
||||
// or `post` would re-run this on every keystroke elsewhere in the app.
|
||||
void (async () => {
|
||||
try {
|
||||
// The connection FIRST. Without it the frame loads and every request inside it answers 503,
|
||||
// which reads as Gitea being down rather than as nobody having said where Gitea is.
|
||||
const conn = await get<ConnectionState>('/gitea/_config');
|
||||
if (cancelled) return;
|
||||
setConnection(conn);
|
||||
if (!conn.configured) {
|
||||
setState('ready');
|
||||
return;
|
||||
}
|
||||
await post('/gitea/session', {});
|
||||
if (!cancelled) setState('ready');
|
||||
} catch (err) {
|
||||
@@ -46,7 +58,7 @@ export const GiteaFrame = () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [attempt]);
|
||||
|
||||
if (state === 'opening') {
|
||||
return (
|
||||
@@ -71,6 +83,10 @@ export const GiteaFrame = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (connection && !connection.configured) {
|
||||
return <GiteaConnection state={connection} onSaved={() => setAttempt((n) => n + 1)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{/* The escape hatch, and it is not an admission of defeat: the whole point of proxying an app that
|
||||
|
||||
Reference in New Issue
Block a user