diff --git a/web/GiteaConnection.tsx b/web/GiteaConnection.tsx new file mode 100644 index 0000000..896820a --- /dev/null +++ b/web/GiteaConnection.tsx @@ -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(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 ( +
+
+

Connect Gitea

+

+ {state.isOwner === false + ? 'Your own access token. The instance is already set.' + : 'Where Gitea runs, and a token for it.'} +

+
+ + {state.isOwner !== false && ( + + )} + + + + {error &&

{error}

} + + +
+ ); +}; diff --git a/web/GiteaFrame.tsx b/web/GiteaFrame.tsx index 462d78d..546f6d5 100644 --- a/web/GiteaFrame.tsx +++ b/web/GiteaFrame.tsx @@ -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(null); const [error, setError] = useState(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('/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 setAttempt((n) => n + 1)} />; + } + return (
{/* The escape hatch, and it is not an admission of defeat: the whole point of proxying an app that