Files
gitea/web/GiteaFrame.tsx
T
officer b1b9e2afc3 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.
2026-08-19 08:36:05 +00:00

115 lines
4.5 KiB
TypeScript

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.
//
// ── Why an iframe and not panels ──
//
// Gitea has a complete web interface. The version of this that shipped before was seventeen files
// reimplementing a fraction of it, and every one of those files was a promise to keep up with a project
// that moves faster than this plugin will.
//
// ── The one thing this component must do ──
//
// Get a cookie before the frame loads. Everything inside the iframe — stylesheets, scripts, avatars — is
// fetched by the browser with no Authorization header, so the frame's URL is served above the platform's
// gate and authenticates with a path-scoped cookie instead. That cookie is minted by a normal
// authenticated call, which is what this does first.
//
// Load the iframe before that call returns and every request in it is unauthenticated, which reads as
// Gitea being down.
const FRAME_SRC = '/api/gitea/_web/';
export const GiteaFrame = () => {
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 `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) {
if (cancelled) return;
setError(err instanceof Error ? err.message : 'could not open a session');
setState('failed');
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [attempt]);
if (state === 'opening') {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span className="text-sm">Opening Gitea…</span>
</div>
);
}
if (state === 'failed') {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<TriangleAlert className="h-6 w-6 text-warning" />
<div>
<p className="text-sm font-medium">Could not open Gitea</p>
{/* The message, not a generic one: the interesting failure here is almost always "the sidecar
is not running" or "no instance is configured", and those want different answers. */}
<p className="mt-1 text-xs text-muted-foreground">{error}</p>
</div>
</div>
);
}
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
already has a UI is that its own UI stays available. Anything the frame handles badly, this
opens properly. */}
<Button
size="sm"
variant="outline"
className="absolute right-3 top-3 z-10 opacity-60 hover:opacity-100"
onClick={() => window.open(FRAME_SRC, '_blank', 'noopener')}
title="Open Gitea in a new tab"
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
<iframe
src={FRAME_SRC}
title="Gitea"
className="h-full w-full border-0"
// Same origin, so `allow-same-origin` is what lets Gitea keep its session at all. Scripts and
// forms are Gitea's own — this is its interface, not untrusted content.
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-downloads"
/>
</div>
);
};